sveltekit-admin 0.2.1 → 0.5.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 (72) hide show
  1. package/README.md +80 -2
  2. package/dist/index.d.ts +2 -7
  3. package/dist/index.js +2 -13
  4. package/dist/server/auth.d.ts +7 -0
  5. package/dist/server/auth.js +19 -0
  6. package/dist/server/data.d.ts +38 -0
  7. package/dist/server/data.js +116 -0
  8. package/dist/server/handler.d.ts +123 -4
  9. package/dist/server/handler.js +590 -819
  10. package/dist/server/introspection/parser.d.ts +19 -12
  11. package/dist/server/introspection/parser.js +71 -61
  12. package/dist/server/introspection/relations.d.ts +49 -0
  13. package/dist/server/introspection/relations.js +128 -0
  14. package/dist/server/query/filterDetection.d.ts +71 -0
  15. package/dist/server/query/filterDetection.js +153 -0
  16. package/dist/server/query/listQuery.d.ts +89 -0
  17. package/dist/server/query/listQuery.js +428 -0
  18. package/dist/server/query/urls.d.ts +33 -0
  19. package/dist/server/query/urls.js +58 -0
  20. package/dist/server/router.d.ts +6 -0
  21. package/dist/server/router.js +27 -0
  22. package/dist/server/views/Dashboard.svelte +29 -0
  23. package/dist/server/views/Dashboard.svelte.d.ts +15 -0
  24. package/dist/server/views/FieldInput.svelte +63 -0
  25. package/dist/server/views/FieldInput.svelte.d.ts +9 -0
  26. package/dist/server/views/Form.svelte +127 -0
  27. package/dist/server/views/Form.svelte.d.ts +12 -0
  28. package/dist/server/views/Layout.svelte +78 -0
  29. package/dist/server/views/Layout.svelte.d.ts +13 -0
  30. package/dist/server/views/List.svelte +240 -0
  31. package/dist/server/views/List.svelte.d.ts +27 -0
  32. package/dist/server/views/ListFilters.svelte +257 -0
  33. package/dist/server/views/ListFilters.svelte.d.ts +18 -0
  34. package/dist/server/views/ModelCard.svelte +11 -0
  35. package/dist/server/views/ModelCard.svelte.d.ts +8 -0
  36. package/dist/server/views/NotFound.svelte +7 -0
  37. package/dist/server/views/NotFound.svelte.d.ts +7 -0
  38. package/dist/server/views/RelatedBlock.svelte +74 -0
  39. package/dist/server/views/RelatedBlock.svelte.d.ts +11 -0
  40. package/dist/server/views/RelationCheckboxes.svelte +53 -0
  41. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  42. package/dist/server/views/RelationSelect.svelte +53 -0
  43. package/dist/server/views/RelationSelect.svelte.d.ts +12 -0
  44. package/dist/server/views/StatCard.svelte +18 -0
  45. package/dist/server/views/StatCard.svelte.d.ts +8 -0
  46. package/dist/server/views/html.d.ts +5 -0
  47. package/dist/server/views/html.js +41 -0
  48. package/dist/server/views/theme.d.ts +1 -0
  49. package/dist/server/views/theme.js +484 -0
  50. package/dist/server/views/types.d.ts +57 -0
  51. package/dist/server/views/types.js +1 -0
  52. package/package.json +24 -26
  53. package/dist/admin.d.ts +0 -227
  54. package/dist/admin.js +0 -369
  55. package/dist/components/AdminForm.svelte +0 -423
  56. package/dist/components/AdminForm.svelte.d.ts +0 -30
  57. package/dist/components/AdminLayout.svelte +0 -328
  58. package/dist/components/AdminLayout.svelte.d.ts +0 -20
  59. package/dist/components/DataTable.svelte +0 -573
  60. package/dist/components/DataTable.svelte.d.ts +0 -25
  61. package/dist/components/index.d.ts +0 -3
  62. package/dist/components/index.js +0 -3
  63. package/dist/server/auth/guard.d.ts +0 -36
  64. package/dist/server/auth/guard.js +0 -38
  65. package/dist/server/auth/index.d.ts +0 -1
  66. package/dist/server/auth/index.js +0 -1
  67. package/dist/server/crud/index.d.ts +0 -1
  68. package/dist/server/crud/index.js +0 -1
  69. package/dist/server/crud/operations.d.ts +0 -87
  70. package/dist/server/crud/operations.js +0 -276
  71. package/dist/server/introspection/index.d.ts +0 -1
  72. package/dist/server/introspection/index.js +0 -1
@@ -13,6 +13,8 @@ export interface PrismaField {
13
13
  isCreatedAt: boolean;
14
14
  hasDefault: boolean;
15
15
  defaultValue?: string;
16
+ /** true si `type` correspond à un `enum` déclaré dans le même schéma. */
17
+ isEnum?: boolean;
16
18
  relation?: {
17
19
  name?: string;
18
20
  model: string;
@@ -26,26 +28,31 @@ export interface PrismaModel {
26
28
  fields: PrismaField[];
27
29
  documentation?: string;
28
30
  primaryKey?: string;
31
+ isPivotTable?: boolean;
29
32
  }
30
33
  export interface PrismaSchema {
31
34
  models: PrismaModel[];
32
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;
33
44
  }
34
45
  export declare function parsePrismaSchema(schemaPath: string): PrismaSchema;
35
46
  export declare function parseSchemaContent(content: string): PrismaSchema;
36
47
  /**
37
- * Get display fields for a model (fields suitable for list view)
38
- */
39
- export declare function getDisplayFields(model: PrismaModel): PrismaField[];
40
- /**
41
- * Get editable fields for a model (fields that can be edited in forms)
48
+ * Un champ est-il sensible par son nom ? Prédicat UNIQUE, partagé par
49
+ * `getDisplayFields` (liste) et par le module de recherche/filtre
50
+ * (`query/listQuery.ts`) — sans ce partage, les deux heuristiques
51
+ * divergeraient tôt ou tard et un champ masqué de la liste redeviendrait
52
+ * cherchable/filtrable par URL forgée.
42
53
  */
43
- export declare function getEditableFields(model: PrismaModel): PrismaField[];
54
+ export declare function isSensitiveFieldName(name: string): boolean;
44
55
  /**
45
- * Get a human-readable label from a field name
46
- */
47
- export declare function fieldToLabel(fieldName: string): string;
48
- /**
49
- * Determine the input type for a Prisma field
56
+ * Get display fields for a model (fields suitable for list view)
50
57
  */
51
- export declare function getInputType(field: PrismaField): string;
58
+ export declare function getDisplayFields(model: Pick<PrismaModel, 'fields'>): PrismaField[];
@@ -4,6 +4,36 @@
4
4
  */
5
5
  import { readFileSync } from 'fs';
6
6
  const SCALAR_TYPES = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
7
+ /**
8
+ * Detect if a model is a pivot/junction table for many-to-many relations.
9
+ * A pivot table typically:
10
+ * - Has a name starting with _ (Prisma implicit)
11
+ * - Has mostly foreign key fields (relations)
12
+ * - Has few or no "business" fields beyond IDs and timestamps
13
+ */
14
+ function detectPivotTable(modelName, fields) {
15
+ // Prisma implicit many-to-many tables start with _
16
+ if (modelName.startsWith('_')) {
17
+ return true;
18
+ }
19
+ // Count different field types
20
+ const relationFields = fields.filter(f => f.relation);
21
+ const idFields = fields.filter(f => f.isId);
22
+ const timestampFields = fields.filter(f => f.isCreatedAt || f.isUpdatedAt);
23
+ const fkFields = fields.filter(f => f.name.toLowerCase().endsWith('id') &&
24
+ !f.isId &&
25
+ SCALAR_TYPES.includes(f.type));
26
+ // Total "structural" fields (not business data)
27
+ const structuralFields = idFields.length + timestampFields.length + fkFields.length + relationFields.length;
28
+ // If almost all fields are structural (IDs, FKs, relations, timestamps)
29
+ // and we have at least 2 FK/relation fields, it's likely a pivot table
30
+ const totalFields = fields.length;
31
+ const businessFields = totalFields - structuralFields;
32
+ // Pivot table: has 2+ relations/FKs and 0-1 business fields
33
+ const hasMultipleRelations = (relationFields.length + fkFields.length) >= 2;
34
+ const hasMinimalBusinessFields = businessFields <= 1;
35
+ return hasMultipleRelations && hasMinimalBusinessFields;
36
+ }
7
37
  export function parsePrismaSchema(schemaPath) {
8
38
  const content = readFileSync(schemaPath, 'utf-8');
9
39
  return parseSchemaContent(content);
@@ -22,6 +52,12 @@ export function parseSchemaContent(content) {
22
52
  .filter(line => line && !line.startsWith('//'));
23
53
  enums.set(enumName, enumValues);
24
54
  }
55
+ // Parse provider du bloc datasource. On ne capture qu'une valeur littérale
56
+ // entre guillemets — `provider = env("DB_PROVIDER")` ou toute expression
57
+ // laisse `provider` à `undefined`, volontairement : le code appelant doit
58
+ // alors dégrader vers le comportement le plus prudent plutôt que deviner.
59
+ const providerMatch = content.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"([^"]+)"/);
60
+ const provider = providerMatch?.[1];
25
61
  // Parse models
26
62
  const modelRegex = /(?:\/\/\/\s*(.+)\n)?model\s+(\w+)\s*\{([^}]+)\}/g;
27
63
  let modelMatch;
@@ -31,14 +67,16 @@ export function parseSchemaContent(content) {
31
67
  const modelBody = modelMatch[3];
32
68
  const fields = parseModelFields(modelBody, enums);
33
69
  const primaryKey = fields.find(f => f.isId)?.name || 'id';
70
+ const isPivotTable = detectPivotTable(modelName, fields);
34
71
  models.push({
35
72
  name: modelName,
36
73
  fields,
37
74
  documentation,
38
- primaryKey
75
+ primaryKey,
76
+ isPivotTable
39
77
  });
40
78
  }
41
- return { models, enums };
79
+ return { models, enums, provider };
42
80
  }
43
81
  function parseModelFields(modelBody, enums) {
44
82
  const fields = [];
@@ -85,21 +123,29 @@ function parseFieldLine(line, enums, documentation) {
85
123
  (type === 'DateTime' && /@default\s*\(\s*now\s*\(\s*\)\s*\)/.test(attributes));
86
124
  // Parse default value
87
125
  let defaultValue;
88
- const defaultMatch = attributes.match(/@default\s*\(([^)]+)\)/);
126
+ // Le motif accepte un niveau d'imbrication : sans lui, `[^)]+` s'arrêtait à la
127
+ // première parenthèse fermante et tronquait tout défaut sous forme d'appel
128
+ // (`autoincrement()` → 'autoincrement('). Couvre autoincrement/now/cuid/uuid et
129
+ // dbgenerated("…") sans appel imbriqué dans la chaîne.
130
+ const defaultMatch = attributes.match(/@default\s*\(((?:[^()]|\([^)]*\))*)\)/);
89
131
  if (defaultMatch) {
90
132
  defaultValue = defaultMatch[1].trim();
91
133
  }
92
- // Parse relation
134
+ // Parse relation. Le motif accepte un niveau d'imbrication pour les
135
+ // arguments comme `fields: [authorId]` — sans lui, `[^)]+` s'arrêtait à la
136
+ // première parenthèse fermante et tronquait `name: "..."` placé après.
93
137
  let relation;
94
- const relationMatch = attributes.match(/@relation\s*\(([^)]*)\)/);
138
+ const relationMatch = attributes.match(/@relation\s*\(((?:[^()]|\([^)]*\))*)\)/);
95
139
  if (relationMatch || (!SCALAR_TYPES.includes(type) && !enums.has(type))) {
96
140
  relation = {
97
141
  model: type,
98
142
  };
99
143
  if (relationMatch) {
100
144
  const relContent = relationMatch[1];
101
- // Parse relation name
102
- const nameMatch = relContent.match(/name:\s*"([^"]+)"/);
145
+ // Parse relation name : `name: "X"` (nommé) ou `"X"` en première
146
+ // position (chaîne positionnelle, forme utilisée côté back-reference).
147
+ const nameMatch = relContent.match(/name:\s*"([^"]+)"/) ??
148
+ relContent.match(/^\s*"([^"]+)"/);
103
149
  if (nameMatch)
104
150
  relation.name = nameMatch[1];
105
151
  // Parse fields
@@ -125,69 +171,33 @@ function parseFieldLine(line, enums, documentation) {
125
171
  isCreatedAt,
126
172
  hasDefault,
127
173
  defaultValue,
174
+ isEnum: enums.has(type),
128
175
  relation,
129
176
  documentation
130
177
  };
131
178
  }
132
179
  /**
133
- * Get display fields for a model (fields suitable for list view)
180
+ * Nom de champ considéré comme sensible : la comparaison est une inclusion en
181
+ * minuscules, donc 'password' couvre `hashedPassword`, 'hash' couvre
182
+ * `passwordHash` et 'token' couvre `apiToken`/`accessToken`/`refreshToken`.
134
183
  */
135
- export function getDisplayFields(model) {
136
- return model.fields.filter(f => !f.relation?.fields && // Skip relation foreign keys shown separately
137
- !f.isList && // Skip array fields
138
- !['password', 'hashedPassword', 'hash', 'secret'].some(hidden => f.name.toLowerCase().includes(hidden)));
139
- }
184
+ const SENSITIVE_FIELD_NAMES = ['password', 'hash', 'secret', 'token'];
140
185
  /**
141
- * Get editable fields for a model (fields that can be edited in forms)
186
+ * Un champ est-il sensible par son nom ? Prédicat UNIQUE, partagé par
187
+ * `getDisplayFields` (liste) et par le module de recherche/filtre
188
+ * (`query/listQuery.ts`) — sans ce partage, les deux heuristiques
189
+ * divergeraient tôt ou tard et un champ masqué de la liste redeviendrait
190
+ * cherchable/filtrable par URL forgée.
142
191
  */
143
- export function getEditableFields(model) {
144
- return model.fields.filter(f => !f.isId &&
145
- !f.isCreatedAt &&
146
- !f.isUpdatedAt &&
147
- !f.isList &&
148
- !f.relation?.references // Skip the "other side" of relations
149
- );
192
+ export function isSensitiveFieldName(name) {
193
+ const lower = name.toLowerCase();
194
+ return SENSITIVE_FIELD_NAMES.some((hidden) => lower.includes(hidden));
150
195
  }
151
196
  /**
152
- * Get a human-readable label from a field name
153
- */
154
- export function fieldToLabel(fieldName) {
155
- return fieldName
156
- .replace(/([A-Z])/g, ' $1')
157
- .replace(/^./, str => str.toUpperCase())
158
- .trim();
159
- }
160
- /**
161
- * Determine the input type for a Prisma field
197
+ * Get display fields for a model (fields suitable for list view)
162
198
  */
163
- export function getInputType(field) {
164
- if (field.relation)
165
- return 'relation';
166
- switch (field.type) {
167
- case 'String':
168
- if (field.name.toLowerCase().includes('email'))
169
- return 'email';
170
- if (field.name.toLowerCase().includes('password'))
171
- return 'password';
172
- if (field.name.toLowerCase().includes('url'))
173
- return 'url';
174
- if (field.name.toLowerCase().includes('description') ||
175
- field.name.toLowerCase().includes('content') ||
176
- field.name.toLowerCase().includes('bio'))
177
- return 'textarea';
178
- return 'text';
179
- case 'Int':
180
- case 'Float':
181
- case 'Decimal':
182
- case 'BigInt':
183
- return 'number';
184
- case 'Boolean':
185
- return 'checkbox';
186
- case 'DateTime':
187
- return 'datetime';
188
- case 'Json':
189
- return 'json';
190
- default:
191
- return 'text';
192
- }
199
+ export function getDisplayFields(model) {
200
+ return model.fields.filter(f => !f.relation?.fields && // Skip relation foreign keys shown separately
201
+ !f.isList && // Skip array fields
202
+ !isSensitiveFieldName(f.name));
193
203
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Relation graph builder.
3
+ *
4
+ * Passe de post-traitement sur l'AST brut du parser : apparie les champs
5
+ * relation par clé (modelA, modelB, relationName), classifie chaque arête,
6
+ * et produit les maps de lien entre scalaires FK et relations.
7
+ *
8
+ * Référence design : docs/design/relations.md §1.
9
+ *
10
+ * Règle d'or : ne JAMAIS apparier deux champs relation par « ils pointent
11
+ * vers le même modèle » — `Post { author User, reviewer User }` produirait
12
+ * un appariement aléatoire et silencieusement faux. L'appariement se fait
13
+ * par nom de relation, que Prisma garantit unique pour un couple de modèles.
14
+ */
15
+ import type { PrismaModel, PrismaSchema } from './parser.js';
16
+ export type RelationKind = 'to-one-owning' | 'to-one-inverse' | 'to-many-inverse' | 'm2m-implicit';
17
+ export type UnsupportedReason = 'composite-fk' | 'ambiguous';
18
+ export interface RelationEdge {
19
+ /** Modèle porteur du champ */
20
+ model: string;
21
+ /** Nom du champ relation */
22
+ field: string;
23
+ kind: RelationKind;
24
+ /** Modèle cible */
25
+ target: string;
26
+ /** Nom de relation (@relation("...")), chaîne vide si absent */
27
+ relationName: string;
28
+ isRequired: boolean;
29
+ isList: boolean;
30
+ /** Noms des scalaires FK portés par ce champ (vide si non owning) */
31
+ scalarFields: string[];
32
+ selfReferential: boolean;
33
+ /** false quand la back-reference n'existe pas dans le schéma */
34
+ hasBackReference: boolean;
35
+ unsupported?: UnsupportedReason;
36
+ }
37
+ export interface RelationGraph {
38
+ /** Arêtes indexées par "Model.field" */
39
+ edges: Map<string, RelationEdge>;
40
+ /** "authorId" → "author" (nom du champ relation owning) */
41
+ scalarToRelation: Map<string, string>;
42
+ /** "Post.author" → ["authorId"] */
43
+ relationToScalars: Map<string, string[]>;
44
+ /** Diagnostics non bloquants (groupes ambigus, etc.) */
45
+ diagnostics: string[];
46
+ }
47
+ export declare function buildRelationGraph(schema: PrismaSchema): RelationGraph;
48
+ /** Raccourci : arêtes d'un modèle donné, dans l'ordre déclaré du schéma. */
49
+ export declare function relationsOf(model: PrismaModel, graph: RelationGraph): RelationEdge[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Relation graph builder.
3
+ *
4
+ * Passe de post-traitement sur l'AST brut du parser : apparie les champs
5
+ * relation par clé (modelA, modelB, relationName), classifie chaque arête,
6
+ * et produit les maps de lien entre scalaires FK et relations.
7
+ *
8
+ * Référence design : docs/design/relations.md §1.
9
+ *
10
+ * Règle d'or : ne JAMAIS apparier deux champs relation par « ils pointent
11
+ * vers le même modèle » — `Post { author User, reviewer User }` produirait
12
+ * un appariement aléatoire et silencieusement faux. L'appariement se fait
13
+ * par nom de relation, que Prisma garantit unique pour un couple de modèles.
14
+ */
15
+ const key = (model, field) => `${model}.${field}`;
16
+ /**
17
+ * Clé d'appariement normalisée : les deux noms de modèle triés + le nom de
18
+ * relation. Triés parce que l'arête owning et l'arête inverse déclarent le
19
+ * même couple dans l'ordre inverse. Les relations self-referential ont
20
+ * naturellement (A, A, name).
21
+ */
22
+ function pairKey(modelA, modelB, relationName) {
23
+ const [lo, hi] = [modelA, modelB].sort();
24
+ return `${lo}|${hi}|${relationName}`;
25
+ }
26
+ export function buildRelationGraph(schema) {
27
+ const modelNames = new Set(schema.models.map((m) => m.name));
28
+ const edges = new Map();
29
+ const scalarToRelation = new Map();
30
+ const relationToScalars = new Map();
31
+ const diagnostics = [];
32
+ // 1. Collecter les champs dont le type est un nom de modèle.
33
+ const candidates = [];
34
+ for (const model of schema.models) {
35
+ for (const field of model.fields) {
36
+ if (!field.relation || !modelNames.has(field.relation.model))
37
+ continue;
38
+ candidates.push({
39
+ model: model.name,
40
+ field,
41
+ relationName: field.relation.name ?? ''
42
+ });
43
+ }
44
+ }
45
+ // 2. Grouper par clé d'appariement.
46
+ const groups = new Map();
47
+ for (const c of candidates) {
48
+ const pk = pairKey(c.model, c.field.relation.model, c.relationName);
49
+ const group = groups.get(pk) ?? [];
50
+ group.push(c);
51
+ groups.set(pk, group);
52
+ }
53
+ // 3. Classifier chaque groupe.
54
+ for (const [, group] of groups) {
55
+ if (group.length > 2) {
56
+ // Schéma invalide ou bug d'appariement : ne pas deviner. On marque
57
+ // toutes les arêtes du groupe comme ambiguës et on passe à la suite.
58
+ diagnostics.push(`Ambiguous relation group (${group.map((c) => key(c.model, c.field.name)).join(', ')}) — editing disabled`);
59
+ for (const c of group) {
60
+ edges.set(key(c.model, c.field.name), makeEdge(c, {
61
+ kind: 'to-one-owning',
62
+ hasBackReference: true,
63
+ unsupported: 'ambiguous'
64
+ }));
65
+ }
66
+ continue;
67
+ }
68
+ const owning = group.filter((c) => !c.field.isList && (c.field.relation.fields?.length ?? 0) > 0);
69
+ // N-N implicite : groupe de 2, list des deux côtés, aucun fields.
70
+ const [a, b] = group;
71
+ const isImplicitM2M = group.length === 2 && a.field.isList && b.field.isList
72
+ ? !a.field.relation.fields?.length && !b.field.relation.fields?.length
73
+ : false;
74
+ for (const c of group) {
75
+ let kind;
76
+ if (isImplicitM2M) {
77
+ kind = 'm2m-implicit';
78
+ }
79
+ else if (c.field.isList) {
80
+ kind = 'to-many-inverse';
81
+ }
82
+ else if (owning.includes(c)) {
83
+ kind = 'to-one-owning';
84
+ }
85
+ else {
86
+ kind = 'to-one-inverse';
87
+ }
88
+ const hasBackReference = group.length === 2;
89
+ const owningFields = c.field.relation.fields;
90
+ const edge = makeEdge(c, { kind, hasBackReference });
91
+ // FK composite : un <option value> ne peut pas porter un tuple.
92
+ if (kind === 'to-one-owning' && owningFields && owningFields.length > 1) {
93
+ edge.unsupported = 'composite-fk';
94
+ diagnostics.push(`Composite FK on ${key(c.model, c.field.name)} — editing disabled, use raw-id`);
95
+ }
96
+ edges.set(key(c.model, c.field.name), edge);
97
+ // 4. Lien scalaires FK ↔ relation owning.
98
+ if (kind === 'to-one-owning' && owningFields) {
99
+ relationToScalars.set(key(c.model, c.field.name), owningFields);
100
+ for (const s of owningFields) {
101
+ scalarToRelation.set(s, c.field.name);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return { edges, scalarToRelation, relationToScalars, diagnostics };
107
+ }
108
+ function makeEdge(c, opts) {
109
+ return {
110
+ model: c.model,
111
+ field: c.field.name,
112
+ kind: opts.kind,
113
+ target: c.field.relation.model,
114
+ relationName: c.relationName,
115
+ isRequired: c.field.isRequired,
116
+ isList: c.field.isList,
117
+ scalarFields: c.field.relation.fields ?? [],
118
+ selfReferential: c.model === c.field.relation.model,
119
+ hasBackReference: opts.hasBackReference,
120
+ unsupported: opts.unsupported
121
+ };
122
+ }
123
+ /** Raccourci : arêtes d'un modèle donné, dans l'ordre déclaré du schéma. */
124
+ export function relationsOf(model, graph) {
125
+ return model.fields
126
+ .filter((f) => graph.edges.has(`${model.name}.${f.name}`))
127
+ .map((f) => graph.edges.get(`${model.name}.${f.name}`));
128
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Filter sidebar detection and configuration resolution.
3
+ *
4
+ * Design reference: docs/design/list-search-filters.md §3.5, §5.5, §6, §8.
5
+ *
6
+ * Auto-detection is intentionally narrow — Boolean and enum only — because
7
+ * their value domain is known STATICALLY from the schema (zero extra query
8
+ * to render the sidebar). DateTime, numeric ranges, and FK all require
9
+ * explicit `listFilter` config (DateTime presets are an editorial choice,
10
+ * ranges need two inputs not a fixed set, FK needs a query to load options
11
+ * — see §3.5, §6.2). This is a deliberate perf guard: an auto-detect
12
+ * heuristic that fires `groupBy`/`findMany` on every list render is a trap
13
+ * that can't be removed later without a breaking change.
14
+ */
15
+ import type { PrismaModel } from '../introspection/parser.js';
16
+ import type { RelationGraph } from '../introspection/relations.js';
17
+ export declare const DATETIME_PRESETS: readonly ["today", "7d", "month", "year"];
18
+ export type DateTimePreset = (typeof DATETIME_PRESETS)[number];
19
+ export type ListFilterConfigEntry = string | {
20
+ field: string;
21
+ label?: string;
22
+ /** DateTime only: which shortcuts to offer as sidebar links (default: all four, §5.5). */
23
+ presets?: DateTimePreset[];
24
+ /** Numeric field only: render two `gte`/`lte` inputs in a GET form instead of a fixed link set. */
25
+ range?: boolean;
26
+ };
27
+ export interface ResolvedFilterField {
28
+ field: string;
29
+ label: string;
30
+ kind: 'boolean' | 'enum' | 'datetime' | 'range' | 'fk';
31
+ /** Only present for kind 'enum'. */
32
+ enumValues?: string[];
33
+ /** Only present for kind 'datetime'. */
34
+ presets?: DateTimePreset[];
35
+ }
36
+ /** A filter entry that was configured as an FK scalar (e.g. `authorId` on Post), with its target relation resolved. */
37
+ export interface FkFilterSpec {
38
+ /** Scalar FK field name, e.g. `authorId`. */
39
+ field: string;
40
+ label: string;
41
+ /** Owning relation field name on this model, e.g. `author`. */
42
+ relationField: string;
43
+ /** Target model name, e.g. `User`. */
44
+ targetModel: string;
45
+ }
46
+ /**
47
+ * Validate a `listFilter` config entry against the schema at boot time.
48
+ * Invalid config throws immediately — a developer typo (unknown field,
49
+ * sensitive field, relation, Json/Bytes) should fail loud at startup, not
50
+ * silently produce a sidebar entry that does nothing. This is a DIFFERENT
51
+ * failure mode than a forged URL (§5.4 of the design doc): bad config is a
52
+ * developer error, a bad URL is untrusted input that must degrade quietly.
53
+ */
54
+ export declare function validateListFilterConfig(modelName: string, entries: ListFilterConfigEntry[], model: PrismaModel, relationGraph?: RelationGraph, hidden?: Set<string>): void;
55
+ /**
56
+ * Trouve l'arête to-one-owning qui porte ce scalaire FK sur ce modèle, si
57
+ * elle existe. Lookup direct sur `edges` avec la clé `"Model.field"` — on ne
58
+ * passe PAS par `scalarToRelation` (indexée par nom de champ seul, donc
59
+ * ambiguë si deux modèles ont une FK du même nom, ex: `Post.authorId` et
60
+ * `Comment.authorId`).
61
+ */
62
+ export declare function findFkEdge(relationGraph: RelationGraph, modelName: string, scalarFieldName: string): import("../introspection/relations.js").RelationEdge | undefined;
63
+ /**
64
+ * Resolve the filter sidebar entries for a model: explicit `listFilter`
65
+ * config wins (already validated at boot by `validateListFilterConfig`),
66
+ * otherwise the auto-detect heuristic (Boolean + enum fields) — unless
67
+ * `autoDetect` is explicitly disabled, in which case a model with no
68
+ * explicit `listFilter` gets no sidebar at all rather than a heuristic
69
+ * one it didn't ask for.
70
+ */
71
+ export declare function resolveListFilters(model: PrismaModel, enums: Map<string, string[]>, configured: ListFilterConfigEntry[] | undefined, toLabel: (name: string) => string, relationGraph?: RelationGraph, hidden?: Set<string>, autoDetect?: boolean): ResolvedFilterField[];
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Filter sidebar detection and configuration resolution.
3
+ *
4
+ * Design reference: docs/design/list-search-filters.md §3.5, §5.5, §6, §8.
5
+ *
6
+ * Auto-detection is intentionally narrow — Boolean and enum only — because
7
+ * their value domain is known STATICALLY from the schema (zero extra query
8
+ * to render the sidebar). DateTime, numeric ranges, and FK all require
9
+ * explicit `listFilter` config (DateTime presets are an editorial choice,
10
+ * ranges need two inputs not a fixed set, FK needs a query to load options
11
+ * — see §3.5, §6.2). This is a deliberate perf guard: an auto-detect
12
+ * heuristic that fires `groupBy`/`findMany` on every list render is a trap
13
+ * that can't be removed later without a breaking change.
14
+ */
15
+ import { isSensitiveFieldName } from '../introspection/parser.js';
16
+ const NUMERIC_TYPES = ['Int', 'Float', 'Decimal', 'BigInt'];
17
+ export const DATETIME_PRESETS = ['today', '7d', 'month', 'year'];
18
+ /** Whether a field is eligible for auto-detection: Boolean or enum, not sensitive/hidden/list/relation. */
19
+ function isAutoDetectable(field, hidden) {
20
+ if (field.relation || field.isList)
21
+ return false;
22
+ if (isSensitiveFieldName(field.name))
23
+ return false;
24
+ if (hidden.has(field.name))
25
+ return false;
26
+ return field.type === 'Boolean' || Boolean(field.isEnum);
27
+ }
28
+ /**
29
+ * Validate a `listFilter` config entry against the schema at boot time.
30
+ * Invalid config throws immediately — a developer typo (unknown field,
31
+ * sensitive field, relation, Json/Bytes) should fail loud at startup, not
32
+ * silently produce a sidebar entry that does nothing. This is a DIFFERENT
33
+ * failure mode than a forged URL (§5.4 of the design doc): bad config is a
34
+ * developer error, a bad URL is untrusted input that must degrade quietly.
35
+ */
36
+ export function validateListFilterConfig(modelName, entries, model, relationGraph, hidden = new Set()) {
37
+ for (const entry of entries) {
38
+ const fieldName = typeof entry === 'string' ? entry : entry.field;
39
+ const field = model.fields.find((f) => f.name === fieldName);
40
+ if (!field) {
41
+ throw new Error(`[sveltekit-admin] listFilter: model "${modelName}" has no field "${fieldName}"`);
42
+ }
43
+ if (field.relation || field.isList) {
44
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" is a relation/list field, not filterable`);
45
+ }
46
+ if (['Json', 'Bytes'].includes(field.type)) {
47
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" is a ${field.type} field, not filterable`);
48
+ }
49
+ if (isSensitiveFieldName(fieldName)) {
50
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" looks sensitive by name, refusing to expose it as a filter`);
51
+ }
52
+ if (hidden.has(fieldName)) {
53
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" is listed in \`hidden\`, refusing to expose it as a filter`);
54
+ }
55
+ const range = typeof entry !== 'string' && entry.range;
56
+ const presets = typeof entry !== 'string' ? entry.presets : undefined;
57
+ if (range && !NUMERIC_TYPES.includes(field.type)) {
58
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has range:true but type ${field.type} is not numeric`);
59
+ }
60
+ if (presets && field.type !== 'DateTime') {
61
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has presets but is not a DateTime field`);
62
+ }
63
+ if (presets) {
64
+ const invalid = presets.filter((p) => !DATETIME_PRESETS.includes(p));
65
+ if (invalid.length > 0) {
66
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has unknown preset(s) ${invalid.join(', ')}, ` +
67
+ `expected one of ${DATETIME_PRESETS.join(', ')}`);
68
+ }
69
+ }
70
+ // Un scalaire FK (to-one owning) est un cas légitime de config explicite
71
+ // — les options seront chargées et scopées au moment du rendu, jamais
72
+ // auto-détectées (docs/design §3.5, §6).
73
+ const isFk = Boolean(relationGraph && findFkEdge(relationGraph, modelName, fieldName));
74
+ // Champ finalement supporté par la sidebar si : Boolean, enum, DateTime
75
+ // (avec ou sans presets), numérique avec range:true, ou scalaire FK.
76
+ // Tout le reste (String libre, Int/Float sans range, Json déjà rejeté
77
+ // plus haut) est refusé — pas de filtre silencieusement mort.
78
+ const supported = field.type === 'Boolean' ||
79
+ field.isEnum ||
80
+ field.type === 'DateTime' ||
81
+ (range && NUMERIC_TYPES.includes(field.type)) ||
82
+ isFk;
83
+ if (!supported) {
84
+ throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has type ${field.type}, ` +
85
+ `only Boolean, enum, DateTime, range:true numeric, and FK scalar fields are supported by the sidebar filter`);
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Trouve l'arête to-one-owning qui porte ce scalaire FK sur ce modèle, si
91
+ * elle existe. Lookup direct sur `edges` avec la clé `"Model.field"` — on ne
92
+ * passe PAS par `scalarToRelation` (indexée par nom de champ seul, donc
93
+ * ambiguë si deux modèles ont une FK du même nom, ex: `Post.authorId` et
94
+ * `Comment.authorId`).
95
+ */
96
+ export function findFkEdge(relationGraph, modelName, scalarFieldName) {
97
+ for (const edge of relationGraph.edges.values()) {
98
+ if (edge.model === modelName &&
99
+ edge.kind === 'to-one-owning' &&
100
+ !edge.unsupported &&
101
+ edge.scalarFields.length === 1 &&
102
+ edge.scalarFields[0] === scalarFieldName) {
103
+ return edge;
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ /**
109
+ * Resolve the filter sidebar entries for a model: explicit `listFilter`
110
+ * config wins (already validated at boot by `validateListFilterConfig`),
111
+ * otherwise the auto-detect heuristic (Boolean + enum fields) — unless
112
+ * `autoDetect` is explicitly disabled, in which case a model with no
113
+ * explicit `listFilter` gets no sidebar at all rather than a heuristic
114
+ * one it didn't ask for.
115
+ */
116
+ export function resolveListFilters(model, enums, configured, toLabel, relationGraph, hidden = new Set(), autoDetect = true) {
117
+ const fieldNames = configured
118
+ ? configured.map((e) => (typeof e === 'string' ? e : e.field))
119
+ : autoDetect
120
+ ? model.fields.filter((f) => isAutoDetectable(f, hidden)).map((f) => f.name)
121
+ : [];
122
+ const entryOf = (fieldName) => configured?.find((e) => (typeof e === 'string' ? e : e.field) === fieldName);
123
+ const labelOf = (fieldName) => {
124
+ const entry = entryOf(fieldName);
125
+ return entry && typeof entry !== 'string' ? entry.label : undefined;
126
+ };
127
+ const out = [];
128
+ for (const fieldName of fieldNames) {
129
+ const field = model.fields.find((f) => f.name === fieldName);
130
+ if (!field)
131
+ continue; // Already validated at boot for explicit config; defensive no-op otherwise.
132
+ const entry = entryOf(fieldName);
133
+ const range = Boolean(entry && typeof entry !== 'string' && entry.range);
134
+ const label = labelOf(fieldName) ?? toLabel(fieldName);
135
+ if (range && NUMERIC_TYPES.includes(field.type)) {
136
+ out.push({ field: fieldName, label, kind: 'range' });
137
+ }
138
+ else if (field.type === 'DateTime') {
139
+ const presets = (entry && typeof entry !== 'string' ? entry.presets : undefined) ?? [...DATETIME_PRESETS];
140
+ out.push({ field: fieldName, label, kind: 'datetime', presets });
141
+ }
142
+ else if (field.type === 'Boolean') {
143
+ out.push({ field: fieldName, label, kind: 'boolean' });
144
+ }
145
+ else if (field.isEnum) {
146
+ out.push({ field: fieldName, label, kind: 'enum', enumValues: enums.get(field.type) ?? [] });
147
+ }
148
+ else if (relationGraph && findFkEdge(relationGraph, model.name, fieldName)) {
149
+ out.push({ field: fieldName, label, kind: 'fk' });
150
+ }
151
+ }
152
+ return out;
153
+ }