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.
- package/README.md +32 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
- package/dist/server/adapters/drizzle/dataAdapter.js +243 -0
- package/dist/server/adapters/drizzle/filterCompiler.d.ts +8 -0
- package/dist/server/adapters/drizzle/filterCompiler.js +61 -0
- package/dist/server/adapters/drizzle/index.d.ts +13 -0
- package/dist/server/adapters/drizzle/index.js +23 -0
- package/dist/server/adapters/drizzle/inspect.d.ts +23 -0
- package/dist/server/adapters/drizzle/inspect.js +220 -0
- package/dist/server/adapters/drizzle/introspector.d.ts +3 -0
- package/dist/server/adapters/drizzle/introspector.js +3 -0
- package/dist/server/adapters/filter.d.ts +12 -0
- package/dist/server/adapters/filter.js +72 -0
- package/dist/server/adapters/prisma/dataAdapter.d.ts +13 -0
- package/dist/server/adapters/prisma/dataAdapter.js +96 -0
- package/dist/server/adapters/prisma/filterCompiler.d.ts +9 -0
- package/dist/server/adapters/prisma/filterCompiler.js +42 -0
- package/dist/server/adapters/prisma/index.d.ts +28 -0
- package/dist/server/adapters/prisma/index.js +32 -0
- package/dist/server/adapters/prisma/introspector.d.ts +4 -0
- package/dist/server/adapters/prisma/introspector.js +9 -0
- package/dist/server/adapters/types.d.ts +79 -0
- package/dist/server/adapters/types.js +1 -0
- package/dist/server/data.d.ts +0 -8
- package/dist/server/data.js +0 -41
- package/dist/server/handler.d.ts +18 -2
- package/dist/server/handler.js +110 -84
- package/dist/server/introspection/parser.d.ts +4 -40
- package/dist/server/introspection/relations.d.ts +1 -1
- package/dist/server/introspection/relations.js +1 -1
- package/dist/server/query/listQuery.d.ts +6 -12
- package/dist/server/query/listQuery.js +31 -53
- package/dist/server/types/schema.d.ts +40 -0
- package/dist/server/types/schema.js +8 -0
- package/dist/server/views/Form.svelte +1 -1
- package/dist/server/views/RelationCheckboxes.svelte +1 -1
- package/package.json +23 -2
package/dist/server/handler.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
86
|
-
const
|
|
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
|
|
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
|
|
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
|
|
162
|
-
const prismaKey = toPrismaModel(edge.target);
|
|
183
|
+
const filter = scopeFrom(relConfig, ctx);
|
|
163
184
|
try {
|
|
164
|
-
const total = await
|
|
185
|
+
const total = await adapter.data.countRecords(targetModel, filter);
|
|
165
186
|
if (total > selectThreshold || relConfig?.widget === 'raw-id') {
|
|
166
|
-
const selectedIds = edge.kind === 'm2m
|
|
167
|
-
? await
|
|
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
|
|
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
|
|
180
|
-
? await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
246
|
-
|
|
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
|
|
303
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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(
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
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
|
|
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
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
560
|
+
await adapter.data.createRecord(model, { scalars: data, m2m: m2mInput });
|
|
535
561
|
}
|
|
536
562
|
else if (route.id) {
|
|
537
|
-
await updateRecord(
|
|
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
|
|
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
|
|
597
|
-
const { items, total } = await listRecords(
|
|
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(
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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 */
|
|
@@ -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
|
|
83
|
-
* NEVER a spread
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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):
|
|
86
|
+
export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): Filter | Record<string, unknown> | undefined;
|