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