sveltekit-admin 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
- package/dist/server/adapters/drizzle/index.d.ts +10 -1
- package/dist/server/adapters/drizzle/index.js +4 -0
- package/dist/server/adapters/prisma/dataAdapter.js +71 -11
- package/dist/server/adapters/prisma/handler.d.ts +14 -0
- package/dist/server/adapters/prisma/handler.js +37 -0
- package/dist/server/adapters/retry.d.ts +27 -0
- package/dist/server/adapters/retry.js +53 -0
- package/dist/server/adapters/types.d.ts +42 -3
- package/dist/server/audit.d.ts +65 -0
- package/dist/server/audit.js +106 -0
- package/dist/server/csrf.d.ts +33 -0
- package/dist/server/csrf.js +55 -0
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +92 -26
- package/dist/server/handler.js +263 -560
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +13 -0
- package/dist/server/mutations.js +476 -0
- package/dist/server/plugin.d.ts +47 -0
- package/dist/server/plugin.js +1 -0
- package/dist/server/pluginAccess.d.ts +7 -0
- package/dist/server/pluginAccess.js +79 -0
- package/dist/server/pluginRegistry.d.ts +12 -0
- package/dist/server/pluginRegistry.js +72 -0
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/listQuery.d.ts +1 -1
- package/dist/server/query/pageSize.d.ts +19 -0
- package/dist/server/query/pageSize.js +26 -0
- package/dist/server/query/sortQuery.d.ts +31 -0
- package/dist/server/query/sortQuery.js +33 -0
- package/dist/server/query/urls.js +9 -1
- package/dist/server/relationLoaders.d.ts +42 -0
- package/dist/server/relationLoaders.js +188 -0
- package/dist/server/router.d.ts +10 -0
- package/dist/server/router.js +42 -19
- package/dist/server/runtime.d.ts +51 -0
- package/dist/server/runtime.js +263 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/submitted.d.ts +20 -0
- package/dist/server/submitted.js +54 -0
- package/dist/server/views/FieldInput.svelte +114 -8
- package/dist/server/views/FieldInput.svelte.d.ts +7 -0
- package/dist/server/views/Form.svelte +96 -5
- package/dist/server/views/Form.svelte.d.ts +19 -1
- package/dist/server/views/Layout.svelte +32 -4
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +156 -26
- package/dist/server/views/List.svelte.d.ts +7 -2
- package/dist/server/views/RelationCheckboxes.svelte +26 -5
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +14 -3
- package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
- package/dist/server/views/html.d.ts +19 -0
- package/dist/server/views/html.js +25 -0
- package/dist/server/views/pagination.d.ts +9 -0
- package/dist/server/views/pagination.js +31 -0
- package/dist/server/views/theme.js +147 -3
- package/dist/server/views/types.d.ts +15 -0
- package/package.json +24 -21
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { normalizeScope } from './adapters/filter.js';
|
|
2
|
+
import { redactForAudit } from './audit.js';
|
|
3
|
+
import { coerceId, primaryKeyOf } from './data.js';
|
|
4
|
+
import { isSensitiveFieldName } from './introspection/parser.js';
|
|
5
|
+
import { listScopeFrom, modelScopeFrom, scopeFrom } from './runtime.js';
|
|
6
|
+
import { filterSelectedIds } from './relationLoaders.js';
|
|
7
|
+
import { escapeHtml } from './views/html.js';
|
|
8
|
+
function andFilters(...parts) {
|
|
9
|
+
const clauses = parts.filter((p) => p !== undefined);
|
|
10
|
+
if (clauses.length === 0)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (clauses.length === 1)
|
|
13
|
+
return clauses[0];
|
|
14
|
+
return { op: 'and', clauses: clauses };
|
|
15
|
+
}
|
|
16
|
+
function redactRow(runtime, model, row) {
|
|
17
|
+
return redactForAudit(row, model, runtime.hiddenFieldsOf(model));
|
|
18
|
+
}
|
|
19
|
+
export function createPluginPageContext(runtime, event, route, record) {
|
|
20
|
+
const loadRecord = async (modelName, id) => {
|
|
21
|
+
const model = runtime.findModel(modelName);
|
|
22
|
+
if (!model)
|
|
23
|
+
return null;
|
|
24
|
+
const scope = andFilters(modelScopeFrom(runtime, model, { locals: event.locals }), normalizeScope(listScopeFrom(runtime, model, { locals: event.locals })));
|
|
25
|
+
const idFilter = {
|
|
26
|
+
op: 'eq',
|
|
27
|
+
field: primaryKeyOf(model),
|
|
28
|
+
value: coerceId(String(id), model)
|
|
29
|
+
};
|
|
30
|
+
const filter = (scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter);
|
|
31
|
+
const row = await runtime.adapter.data.findFirst(model, filter);
|
|
32
|
+
return row ? redactRow(runtime, model, row) : null;
|
|
33
|
+
};
|
|
34
|
+
const listRecords = async (modelName, extraFilter) => {
|
|
35
|
+
const model = runtime.findModel(modelName);
|
|
36
|
+
if (!model)
|
|
37
|
+
return [];
|
|
38
|
+
const scope = andFilters(modelScopeFrom(runtime, model, { locals: event.locals }), normalizeScope(listScopeFrom(runtime, model, { locals: event.locals })));
|
|
39
|
+
const filter = andFilters(scope, extraFilter);
|
|
40
|
+
const rows = await runtime.adapter.data.findMany(model, { filter: filter });
|
|
41
|
+
return rows.map((row) => redactRow(runtime, model, row));
|
|
42
|
+
};
|
|
43
|
+
const getM2mSelectedIds = async (modelName, fieldName, recordId) => {
|
|
44
|
+
const model = runtime.findModel(modelName);
|
|
45
|
+
if (!model) {
|
|
46
|
+
throw new Error(`[sveltekit-admin] getM2mSelectedIds: unknown model "${modelName}"`);
|
|
47
|
+
}
|
|
48
|
+
// Non-null par construction : `model` vient toujours de `runtime.models`,
|
|
49
|
+
// dérivé du schéma qu'on a parsé avec succès (même convention que
|
|
50
|
+
// `runtime.ts` `validateListFilterConfig` / `viewModel`).
|
|
51
|
+
const edge = runtime.relationGraph.edges.get(`${model.name}.${fieldName}`);
|
|
52
|
+
if (!edge || edge.kind !== 'm2m') {
|
|
53
|
+
throw new Error(`[sveltekit-admin] getM2mSelectedIds: "${model.name}.${fieldName}" is not an m2m relation`);
|
|
54
|
+
}
|
|
55
|
+
const target = runtime.findModel(edge.target);
|
|
56
|
+
if (!target) {
|
|
57
|
+
throw new Error(`[sveltekit-admin] getM2mSelectedIds: target model "${edge.target}" is not visible`);
|
|
58
|
+
}
|
|
59
|
+
if (!(await loadRecord(model.name, recordId)))
|
|
60
|
+
return [];
|
|
61
|
+
const selected = await runtime.adapter.data.getM2mSelectedIds(model, edge, target, recordId);
|
|
62
|
+
return (await filterSelectedIds(runtime, target, selected, { locals: event.locals }, scopeFrom(runtime.config.models?.[model.name]?.relations?.[fieldName], { locals: event.locals }))) ?? [];
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
event,
|
|
66
|
+
route,
|
|
67
|
+
basePath: runtime.basePath,
|
|
68
|
+
record,
|
|
69
|
+
escapeHtml,
|
|
70
|
+
findModel: runtime.findModel,
|
|
71
|
+
relationGraph: runtime.relationGraph,
|
|
72
|
+
resolveLabel: runtime.resolveLabel,
|
|
73
|
+
hiddenFieldsOf: runtime.hiddenFieldsOf,
|
|
74
|
+
isSensitiveFieldName,
|
|
75
|
+
loadRecord,
|
|
76
|
+
listRecords,
|
|
77
|
+
getM2mSelectedIds
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouteEntry } from './router.js';
|
|
2
|
+
import type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction } from './plugin.js';
|
|
3
|
+
export interface PluginRegistry {
|
|
4
|
+
routes: RouteEntry[];
|
|
5
|
+
pagesByView: Map<string, AdminPluginPage>;
|
|
6
|
+
recordActions: AdminPluginRecordAction[];
|
|
7
|
+
}
|
|
8
|
+
export declare function pluginViewId(pluginName: string, pattern: string[]): string;
|
|
9
|
+
export declare function resolvePluginRegistry(plugins: AdminPlugin[], builtinRoutes: RouteEntry[], visibleModels: Array<{
|
|
10
|
+
name: string;
|
|
11
|
+
}>): PluginRegistry;
|
|
12
|
+
export declare function actionsForModel(registry: PluginRegistry, modelName: string): AdminPluginRecordAction[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export function pluginViewId(pluginName, pattern) {
|
|
2
|
+
return `plugin/${pluginName}/${pattern.join('/')}`;
|
|
3
|
+
}
|
|
4
|
+
function patternKey(pattern) {
|
|
5
|
+
return pattern.join('\0');
|
|
6
|
+
}
|
|
7
|
+
function modelVisible(visibleModels, entry) {
|
|
8
|
+
return visibleModels.some((m) => m.name.toLowerCase() === entry.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
function assertKnownModels(entries, visibleModels, pluginName) {
|
|
11
|
+
if (!entries)
|
|
12
|
+
return;
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (!modelVisible(visibleModels, entry)) {
|
|
15
|
+
throw new Error(`[sveltekit-admin] plugin "${pluginName}" models[] includes unknown model "${entry}"`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function resolvePluginRegistry(plugins, builtinRoutes, visibleModels) {
|
|
20
|
+
const builtinKeys = new Map(builtinRoutes.map((r) => [patternKey(r.pattern), r.view]));
|
|
21
|
+
const taken = new Map();
|
|
22
|
+
const names = new Set();
|
|
23
|
+
const routes = [];
|
|
24
|
+
const pagesByView = new Map();
|
|
25
|
+
const recordActions = [];
|
|
26
|
+
for (const plugin of plugins) {
|
|
27
|
+
if (!plugin.name) {
|
|
28
|
+
throw new Error('[sveltekit-admin] plugin name must be a non-empty string');
|
|
29
|
+
}
|
|
30
|
+
if (names.has(plugin.name)) {
|
|
31
|
+
throw new Error(`[sveltekit-admin] duplicate plugin name "${plugin.name}"`);
|
|
32
|
+
}
|
|
33
|
+
names.add(plugin.name);
|
|
34
|
+
for (const page of plugin.pages ?? []) {
|
|
35
|
+
for (const token of page.pattern) {
|
|
36
|
+
if (token.startsWith(':') && token !== ':model' && token !== ':id') {
|
|
37
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" pattern token "${token}" is not :model or :id`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const hasModel = page.pattern.includes(':model');
|
|
41
|
+
const hasId = page.pattern.includes(':id');
|
|
42
|
+
if (page.models && !hasModel) {
|
|
43
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" page ${JSON.stringify(page.pattern)} sets models[] but pattern has no :model`);
|
|
44
|
+
}
|
|
45
|
+
if (hasId && !hasModel) {
|
|
46
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" page ${JSON.stringify(page.pattern)} has :id but pattern has no :model`);
|
|
47
|
+
}
|
|
48
|
+
assertKnownModels(page.models, visibleModels, plugin.name);
|
|
49
|
+
const key = patternKey(page.pattern);
|
|
50
|
+
const builtinView = builtinKeys.get(key);
|
|
51
|
+
if (builtinView !== undefined) {
|
|
52
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" pattern ${JSON.stringify(page.pattern)} overlays builtin route "${builtinView}"`);
|
|
53
|
+
}
|
|
54
|
+
const other = taken.get(key);
|
|
55
|
+
if (other !== undefined) {
|
|
56
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" pattern ${JSON.stringify(page.pattern)} collides with plugin "${other}"`);
|
|
57
|
+
}
|
|
58
|
+
taken.set(key, plugin.name);
|
|
59
|
+
const view = pluginViewId(plugin.name, page.pattern);
|
|
60
|
+
routes.push({ pattern: page.pattern, view });
|
|
61
|
+
pagesByView.set(view, page);
|
|
62
|
+
}
|
|
63
|
+
for (const action of plugin.recordActions ?? []) {
|
|
64
|
+
assertKnownModels(action.models, visibleModels, plugin.name);
|
|
65
|
+
recordActions.push(action);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { routes, pagesByView, recordActions };
|
|
69
|
+
}
|
|
70
|
+
export function actionsForModel(registry, modelName) {
|
|
71
|
+
return registry.recordActions.filter((action) => !action.models || action.models.some((n) => n.toLowerCase() === modelName.toLowerCase()));
|
|
72
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colonnes réellement rendues par la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Extrait de `List.svelte`, où cette composition vivait dans un `$derived` de
|
|
5
|
+
* composant : le handler en a besoin CÔTÉ SERVEUR pour décider quels champs un
|
|
6
|
+
* `?sort=` a le droit de désigner. Deux implémentations de « ce que la liste
|
|
7
|
+
* affiche » finiraient par diverger, et un tri autorisé sur une colonne que la
|
|
8
|
+
* liste n'affiche pas est un oracle — `?sort=passwordHash` plus la pagination
|
|
9
|
+
* suffit à ordonner des secrets par dichotomie. Même raison que le prédicat de
|
|
10
|
+
* sensibilité partagé (`parser.ts`) : une seule source, jamais deux.
|
|
11
|
+
*
|
|
12
|
+
* `getDisplayFields` reste la première passe (relations, listes, noms
|
|
13
|
+
* sensibles) ; cette fonction y ajoute ce que seule la config connaît.
|
|
14
|
+
*/
|
|
15
|
+
import { type PrismaField } from '../introspection/parser.js';
|
|
16
|
+
export declare function resolveListColumns(fields: PrismaField[], opts: {
|
|
17
|
+
hidden?: string[];
|
|
18
|
+
listFields?: string[];
|
|
19
|
+
}): PrismaField[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colonnes réellement rendues par la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Extrait de `List.svelte`, où cette composition vivait dans un `$derived` de
|
|
5
|
+
* composant : le handler en a besoin CÔTÉ SERVEUR pour décider quels champs un
|
|
6
|
+
* `?sort=` a le droit de désigner. Deux implémentations de « ce que la liste
|
|
7
|
+
* affiche » finiraient par diverger, et un tri autorisé sur une colonne que la
|
|
8
|
+
* liste n'affiche pas est un oracle — `?sort=passwordHash` plus la pagination
|
|
9
|
+
* suffit à ordonner des secrets par dichotomie. Même raison que le prédicat de
|
|
10
|
+
* sensibilité partagé (`parser.ts`) : une seule source, jamais deux.
|
|
11
|
+
*
|
|
12
|
+
* `getDisplayFields` reste la première passe (relations, listes, noms
|
|
13
|
+
* sensibles) ; cette fonction y ajoute ce que seule la config connaît.
|
|
14
|
+
*/
|
|
15
|
+
import { getDisplayFields } from '../introspection/parser.js';
|
|
16
|
+
/** Types qu'aucune cellule ne sait rendre lisiblement. */
|
|
17
|
+
const UNRENDERABLE_TYPES = ['Json', 'Bytes'];
|
|
18
|
+
/** Au-delà, la table déborde horizontalement sur un écran ordinaire. */
|
|
19
|
+
const MAX_COLUMNS = 6;
|
|
20
|
+
export function resolveListColumns(fields, opts) {
|
|
21
|
+
const hidden = opts.hidden ?? [];
|
|
22
|
+
const listFields = opts.listFields;
|
|
23
|
+
const explicit = new Set(listFields ?? []);
|
|
24
|
+
const safeNames = new Set(getDisplayFields({ fields }).map((f) => f.name));
|
|
25
|
+
let columns = fields.filter((f) =>
|
|
26
|
+
// `listFields` explicite l'emporte sur l'heuristique de nom sensible —
|
|
27
|
+
// échappatoire documentée pour ses faux positifs (`tokenCount`,
|
|
28
|
+
// `hashtagCount`), et couverte par les tests de `List.svelte`. Elle ne
|
|
29
|
+
// l'emporte jamais sur `hidden`, qui est un refus explicite.
|
|
30
|
+
//
|
|
31
|
+
// C'est aussi ce qui rend la whitelist de tri sûre sans règle en plus :
|
|
32
|
+
// trier ne porte que sur des colonnes DÉJÀ rendues, donc sur des valeurs
|
|
33
|
+
// que le lecteur peut lire de toute façon. Le tri n'ouvre aucun oracle
|
|
34
|
+
// qui ne soit pas déjà une lecture directe.
|
|
35
|
+
(explicit.has(f.name) || safeNames.has(f.name)) &&
|
|
36
|
+
!hidden.includes(f.name) &&
|
|
37
|
+
!f.relation &&
|
|
38
|
+
!UNRENDERABLE_TYPES.includes(f.type));
|
|
39
|
+
if (listFields?.length) {
|
|
40
|
+
columns = columns.filter((f) => listFields.includes(f.name));
|
|
41
|
+
}
|
|
42
|
+
return columns.slice(0, MAX_COLUMNS);
|
|
43
|
+
}
|
|
@@ -83,4 +83,4 @@ export declare function parseListQuery(searchParams: URLSearchParams, model: Pri
|
|
|
83
83
|
* `normalizeScope`; nested Prisma where objects stay opaque for the Prisma
|
|
84
84
|
* compiler. Drizzle's compiler throws on those opaques.
|
|
85
85
|
*/
|
|
86
|
-
export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): Filter | Record<string, unknown> | undefined;
|
|
86
|
+
export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | Filter | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): Filter | Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taille de page : celle configurée, et celles qu'un `?perPage=` a le droit de
|
|
3
|
+
* demander.
|
|
4
|
+
*
|
|
5
|
+
* Le point qui compte : la valeur venue de l'URL n'est jamais utilisée telle
|
|
6
|
+
* quelle. Elle doit appartenir à la liste proposée, sinon `?perPage=100000`
|
|
7
|
+
* devient un `take` non borné — un déni de service à un paramètre près, et sur
|
|
8
|
+
* une table volumineuse une requête qui tient la connexion. C'est la même règle
|
|
9
|
+
* d'or que les opérateurs de filtre et les colonnes de tri : l'URL choisit dans
|
|
10
|
+
* une liste finie, elle ne décrit rien.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Tailles sélectionnables : les options configurées, plus la taille par défaut
|
|
14
|
+
* (sinon elle serait active sans figurer dans le sélecteur), triées et
|
|
15
|
+
* dédoublonnées. Une liste d'options vide désactive entièrement le mécanisme —
|
|
16
|
+
* pas de sélecteur, et `?perPage=` sans effet.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolvePageSizes(perPage: number, options: number[]): number[];
|
|
19
|
+
export declare function parsePageSize(params: URLSearchParams, fallback: number, selectable: number[]): number;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taille de page : celle configurée, et celles qu'un `?perPage=` a le droit de
|
|
3
|
+
* demander.
|
|
4
|
+
*
|
|
5
|
+
* Le point qui compte : la valeur venue de l'URL n'est jamais utilisée telle
|
|
6
|
+
* quelle. Elle doit appartenir à la liste proposée, sinon `?perPage=100000`
|
|
7
|
+
* devient un `take` non borné — un déni de service à un paramètre près, et sur
|
|
8
|
+
* une table volumineuse une requête qui tient la connexion. C'est la même règle
|
|
9
|
+
* d'or que les opérateurs de filtre et les colonnes de tri : l'URL choisit dans
|
|
10
|
+
* une liste finie, elle ne décrit rien.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Tailles sélectionnables : les options configurées, plus la taille par défaut
|
|
14
|
+
* (sinon elle serait active sans figurer dans le sélecteur), triées et
|
|
15
|
+
* dédoublonnées. Une liste d'options vide désactive entièrement le mécanisme —
|
|
16
|
+
* pas de sélecteur, et `?perPage=` sans effet.
|
|
17
|
+
*/
|
|
18
|
+
export function resolvePageSizes(perPage, options) {
|
|
19
|
+
if (options.length === 0)
|
|
20
|
+
return [];
|
|
21
|
+
return [...new Set([...options, perPage])].sort((a, b) => a - b);
|
|
22
|
+
}
|
|
23
|
+
export function parsePageSize(params, fallback, selectable) {
|
|
24
|
+
const requested = Number(params.get('perPage'));
|
|
25
|
+
return selectable.includes(requested) ? requested : fallback;
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `?sort=<colonne>&dir=asc|desc` → ordre de tri de la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Même règle d'or que les filtres (`listQuery.ts`) : la chaîne venue de l'URL
|
|
5
|
+
* ne devient jamais une clé de requête. Elle est cherchée dans la liste des
|
|
6
|
+
* colonnes que la vue rend réellement (`resolveListColumns`), et seul un membre
|
|
7
|
+
* de cette liste ressort. Une colonne masquée par `hidden`, écartée par
|
|
8
|
+
* l'heuristique de nom, ou simplement tronquée par le plafond de colonnes n'y
|
|
9
|
+
* est pas — donc pas triable.
|
|
10
|
+
*
|
|
11
|
+
* Trier ne peut ainsi ordonner que des valeurs déjà lisibles à l'écran : le tri
|
|
12
|
+
* n'ouvre aucune lecture que la liste n'offrait pas.
|
|
13
|
+
*/
|
|
14
|
+
export type SortDirection = 'asc' | 'desc';
|
|
15
|
+
export interface ActiveSort {
|
|
16
|
+
field: string;
|
|
17
|
+
dir: SortDirection;
|
|
18
|
+
}
|
|
19
|
+
export interface SortState {
|
|
20
|
+
/** null = ordre par défaut (clé primaire décroissante, côté adapter). */
|
|
21
|
+
active: ActiveSort | null;
|
|
22
|
+
/** true quand l'URL a demandé une colonne non triable — rendu comme message. */
|
|
23
|
+
ignored: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `fallback` est le `models[].defaultSort` déjà validé au démarrage. Il
|
|
27
|
+
* s'applique quand l'URL ne demande rien, ET quand elle demande une colonne
|
|
28
|
+
* refusée : le refus reste signalé (`ignored`), mais la liste garde un ordre
|
|
29
|
+
* intentionnel plutôt que de retomber sur la clé primaire.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseSortQuery(params: URLSearchParams, sortable: string[], fallback?: ActiveSort): SortState;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `?sort=<colonne>&dir=asc|desc` → ordre de tri de la vue liste.
|
|
3
|
+
*
|
|
4
|
+
* Même règle d'or que les filtres (`listQuery.ts`) : la chaîne venue de l'URL
|
|
5
|
+
* ne devient jamais une clé de requête. Elle est cherchée dans la liste des
|
|
6
|
+
* colonnes que la vue rend réellement (`resolveListColumns`), et seul un membre
|
|
7
|
+
* de cette liste ressort. Une colonne masquée par `hidden`, écartée par
|
|
8
|
+
* l'heuristique de nom, ou simplement tronquée par le plafond de colonnes n'y
|
|
9
|
+
* est pas — donc pas triable.
|
|
10
|
+
*
|
|
11
|
+
* Trier ne peut ainsi ordonner que des valeurs déjà lisibles à l'écran : le tri
|
|
12
|
+
* n'ouvre aucune lecture que la liste n'offrait pas.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `fallback` est le `models[].defaultSort` déjà validé au démarrage. Il
|
|
16
|
+
* s'applique quand l'URL ne demande rien, ET quand elle demande une colonne
|
|
17
|
+
* refusée : le refus reste signalé (`ignored`), mais la liste garde un ordre
|
|
18
|
+
* intentionnel plutôt que de retomber sur la clé primaire.
|
|
19
|
+
*/
|
|
20
|
+
export function parseSortQuery(params, sortable, fallback) {
|
|
21
|
+
const requested = params.get('sort');
|
|
22
|
+
// Vide == absent : `?sort=` est un artefact d'interface (un form GET qui
|
|
23
|
+
// sérialise un champ non renseigné), pas une demande à refuser bruyamment.
|
|
24
|
+
if (!requested)
|
|
25
|
+
return { active: fallback ?? null, ignored: false };
|
|
26
|
+
if (!sortable.includes(requested))
|
|
27
|
+
return { active: fallback ?? null, ignored: true };
|
|
28
|
+
// Domaine à deux valeurs : tout ce qui n'est pas `desc` est ascendant. Rien à
|
|
29
|
+
// refuser ici — contrairement au champ, une direction inconnue ne désigne
|
|
30
|
+
// aucune colonne et ne peut donc rien révéler.
|
|
31
|
+
const dir = params.get('dir') === 'desc' ? 'desc' : 'asc';
|
|
32
|
+
return { active: { field: requested, dir }, ignored: false };
|
|
33
|
+
}
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
|
|
8
8
|
* and risks parameter-order drift, which makes snapshots flaky.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Paramètres qui ne sont pas un état de liste : un compte rendu d'action posé
|
|
12
|
+
* une fois par une redirection après écriture. Retiré de tout lien construit
|
|
13
|
+
* ensuite, sinon « 3 supprimés » réapparaît à chaque clic de filtre ou de page.
|
|
14
|
+
*/
|
|
15
|
+
const ONE_SHOT_PARAMS = ['deleted'];
|
|
10
16
|
/**
|
|
11
17
|
* Build a list-view URL from the current one, applying a patch of query
|
|
12
18
|
* params. `null` in the patch removes that key. `page` is ALWAYS dropped
|
|
@@ -23,6 +29,8 @@ export function buildListUrl(currentUrl, patch) {
|
|
|
23
29
|
if (!('page' in patch)) {
|
|
24
30
|
params.delete('page');
|
|
25
31
|
}
|
|
32
|
+
for (const key of ONE_SHOT_PARAMS)
|
|
33
|
+
params.delete(key);
|
|
26
34
|
for (const [key, value] of Object.entries(patch)) {
|
|
27
35
|
if (value === null)
|
|
28
36
|
params.delete(key);
|
|
@@ -47,7 +55,7 @@ export function buildListUrl(currentUrl, patch) {
|
|
|
47
55
|
* `page` is always excluded too, since any new search/filter resets it.
|
|
48
56
|
*/
|
|
49
57
|
export function hiddenParams(currentUrl, exclude) {
|
|
50
|
-
const excluded = new Set([...exclude, 'page']);
|
|
58
|
+
const excluded = new Set([...exclude, 'page', ...ONE_SHOT_PARAMS]);
|
|
51
59
|
const out = [];
|
|
52
60
|
for (const [key, value] of currentUrl.searchParams) {
|
|
53
61
|
if (excluded.has(key))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation option loaders shared by the create/edit Form and the list-view
|
|
3
|
+
* FK filter sidebar. Split out of `handler.ts` — pure orchestration over
|
|
4
|
+
* `AdminRuntime` (schema/relationGraph/adapter already resolved at boot),
|
|
5
|
+
* no boot logic lives here.
|
|
6
|
+
*/
|
|
7
|
+
import type { PrismaModel } from './introspection/parser.js';
|
|
8
|
+
import type { RelationMeta, FkFilterMeta } from './views/types.js';
|
|
9
|
+
import { type AdminRuntime } from './runtime.js';
|
|
10
|
+
import type { Filter } from './adapters/types.js';
|
|
11
|
+
export declare function combinedScope(...scopes: Array<Filter | Record<string, unknown> | undefined>): Filter | undefined;
|
|
12
|
+
export declare function filterSelectedIds(runtime: AdminRuntime, targetModel: PrismaModel, ids: Array<string | number> | undefined, ctx: {
|
|
13
|
+
locals?: any;
|
|
14
|
+
}, relationScope?: Filter | Record<string, unknown>): Promise<Array<string | number> | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* Charge les options pour toutes les arêtes to-one-owning et m2m
|
|
17
|
+
* d'un modèle. Une requête COUNT par relation avant le findMany : évite de
|
|
18
|
+
* charger 10k lignes pour découvrir qu'il y en a 10k.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadRelationOptions(runtime: AdminRuntime, model: PrismaModel, ctx: {
|
|
21
|
+
locals?: any;
|
|
22
|
+
}, currentId?: string): Promise<Map<string, RelationMeta>>;
|
|
23
|
+
/**
|
|
24
|
+
* Options d'un filtre FK : charge et scope les valeurs possibles pour la
|
|
25
|
+
* sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
|
|
26
|
+
* §6.3) : les options ET le label du chip passent par le `where` de
|
|
27
|
+
* scoping de la relation — un chip forgé avec un ID hors scope affiche
|
|
28
|
+
* l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
|
|
29
|
+
* enregistrement d'un autre tenant).
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveFkFilterOptions(runtime: AdminRuntime, model: PrismaModel, fkFieldName: string, label: string, ctx: {
|
|
32
|
+
locals?: any;
|
|
33
|
+
}, activeRawValue: string | undefined): Promise<FkFilterMeta>;
|
|
34
|
+
/**
|
|
35
|
+
* Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
|
|
36
|
+
* d'enregistrements liés côté cible. Résilient : une cible dont le client
|
|
37
|
+
* échoue (mock partiel, modèle absent) retombe sur 0 plutôt que de casser
|
|
38
|
+
* le rendu du formulaire.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadRelatedCounts(runtime: AdminRuntime, model: PrismaModel, currentId: string, ctx: {
|
|
41
|
+
locals?: any;
|
|
42
|
+
}): Promise<Map<string, number>>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation option loaders shared by the create/edit Form and the list-view
|
|
3
|
+
* FK filter sidebar. Split out of `handler.ts` — pure orchestration over
|
|
4
|
+
* `AdminRuntime` (schema/relationGraph/adapter already resolved at boot),
|
|
5
|
+
* no boot logic lives here.
|
|
6
|
+
*/
|
|
7
|
+
import { primaryKeyOf, coerceId } from './data.js';
|
|
8
|
+
import { findFkEdge } from './query/filterDetection.js';
|
|
9
|
+
import { scopeFrom, modelScopeFrom } from './runtime.js';
|
|
10
|
+
import { normalizeScope } from './adapters/filter.js';
|
|
11
|
+
export function combinedScope(...scopes) {
|
|
12
|
+
const clauses = scopes.map((scope) => normalizeScope(scope)).filter((scope) => scope !== undefined);
|
|
13
|
+
if (clauses.length === 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (clauses.length === 1)
|
|
16
|
+
return clauses[0];
|
|
17
|
+
return { op: 'and', clauses };
|
|
18
|
+
}
|
|
19
|
+
export async function filterSelectedIds(runtime, targetModel, ids, ctx, relationScope) {
|
|
20
|
+
if (!ids)
|
|
21
|
+
return undefined;
|
|
22
|
+
const scope = combinedScope(modelScopeFrom(runtime, targetModel, ctx), relationScope);
|
|
23
|
+
if (!scope || ids.length === 0)
|
|
24
|
+
return ids;
|
|
25
|
+
const rows = await runtime.adapter.data.findMany(targetModel, {
|
|
26
|
+
filter: combinedScope(scope, { op: 'in', field: primaryKeyOf(targetModel), value: ids })
|
|
27
|
+
});
|
|
28
|
+
const allowed = new Set(rows.map((row) => String(row[primaryKeyOf(targetModel)])));
|
|
29
|
+
return ids.filter((id) => allowed.has(String(id)));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Charge les options pour toutes les arêtes to-one-owning et m2m
|
|
33
|
+
* d'un modèle. Une requête COUNT par relation avant le findMany : évite de
|
|
34
|
+
* charger 10k lignes pour découvrir qu'il y en a 10k.
|
|
35
|
+
*/
|
|
36
|
+
export async function loadRelationOptions(runtime, model, ctx, currentId) {
|
|
37
|
+
const modelsConfig = runtime.config.models ?? {};
|
|
38
|
+
const edges = [...runtime.relationGraph.edges.values()].filter((edge) => {
|
|
39
|
+
if (edge.model !== model.name)
|
|
40
|
+
return false;
|
|
41
|
+
if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m')
|
|
42
|
+
return false;
|
|
43
|
+
if (edge.unsupported)
|
|
44
|
+
return false;
|
|
45
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
46
|
+
return relConfig?.widget !== 'hidden';
|
|
47
|
+
});
|
|
48
|
+
// Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
|
|
49
|
+
// qu'en série (un modèle avec N relations ne doit pas payer N
|
|
50
|
+
// aller-retours DB empilés pour afficher un seul formulaire).
|
|
51
|
+
const entries = await Promise.all(edges.map(async (edge) => {
|
|
52
|
+
const key = `${edge.model}.${edge.field}`;
|
|
53
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
54
|
+
const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
|
|
55
|
+
const filter = combinedScope(modelScopeFrom(runtime, targetModel, ctx), scopeFrom(relConfig, ctx));
|
|
56
|
+
try {
|
|
57
|
+
const total = await runtime.adapter.data.countRecords(targetModel, filter);
|
|
58
|
+
if (total > runtime.selectThreshold || relConfig?.widget === 'raw-id') {
|
|
59
|
+
const selectedIds = edge.kind === 'm2m' && currentId
|
|
60
|
+
? await filterSelectedIds(runtime, targetModel, await runtime.adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId), ctx, scopeFrom(relConfig, ctx))
|
|
61
|
+
: undefined;
|
|
62
|
+
return [key, { tooMany: true, options: [], selectedIds }];
|
|
63
|
+
}
|
|
64
|
+
const rows = await runtime.adapter.data.findMany(targetModel, { filter, orderBy: relConfig?.orderBy });
|
|
65
|
+
const options = rows.map((row) => ({
|
|
66
|
+
id: row[primaryKeyOf(targetModel)],
|
|
67
|
+
label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
68
|
+
}));
|
|
69
|
+
const selectedIds = edge.kind === 'm2m' && currentId
|
|
70
|
+
? await filterSelectedIds(runtime, targetModel, await runtime.adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId), ctx, scopeFrom(relConfig, ctx))
|
|
71
|
+
: undefined;
|
|
72
|
+
return [key, { tooMany: false, options, selectedIds }];
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Cible absente de la base ou client incomplet : repli raw-id pour
|
|
76
|
+
// garder le champ éditable plutôt que de faire échouer tout le form.
|
|
77
|
+
return [key, { tooMany: true, options: [] }];
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
return new Map(entries);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Options d'un filtre FK : charge et scope les valeurs possibles pour la
|
|
84
|
+
* sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
|
|
85
|
+
* §6.3) : les options ET le label du chip passent par le `where` de
|
|
86
|
+
* scoping de la relation — un chip forgé avec un ID hors scope affiche
|
|
87
|
+
* l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
|
|
88
|
+
* enregistrement d'un autre tenant).
|
|
89
|
+
*/
|
|
90
|
+
export async function resolveFkFilterOptions(runtime, model, fkFieldName, label, ctx, activeRawValue) {
|
|
91
|
+
// Appelé uniquement pour un filtre `kind: 'fk'` retourné par
|
|
92
|
+
// resolveListFilters avec CE MÊME graphe : graphe et arête existent donc
|
|
93
|
+
// par construction. Garder des gardes here masquerait une incohérence
|
|
94
|
+
// interne et ajouterait du code mort (coverage artificielle).
|
|
95
|
+
const edge = findFkEdge(runtime.relationGraph, model.name, fkFieldName);
|
|
96
|
+
// Non-null par construction : un filtre `kind: 'fk'` ne peut exister que
|
|
97
|
+
// via `models[model.name].listFilter` explicite (CLAUDE.md — les filtres
|
|
98
|
+
// FK ne sont jamais auto-détectés) ; `runtime.config.models` est donc
|
|
99
|
+
// déjà renseigné pour ce modèle avant que cette fonction ne soit appelée.
|
|
100
|
+
const modelsConfig = runtime.config.models;
|
|
101
|
+
const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
|
|
102
|
+
// Non-null par construction : `edge` vient du graphe dérivé du même
|
|
103
|
+
// schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
|
|
104
|
+
// n'existe pas dans `schema.models`.
|
|
105
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
106
|
+
const scope = combinedScope(modelScopeFrom(runtime, targetModel, ctx), scopeFrom(relConfig, ctx));
|
|
107
|
+
// Options de la sidebar (comptées puis chargées si sous le seuil) et
|
|
108
|
+
// label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
|
|
109
|
+
// ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
|
|
110
|
+
// série.
|
|
111
|
+
const loadOptions = async () => {
|
|
112
|
+
try {
|
|
113
|
+
const total = await runtime.adapter.data.countRecords(targetModel, scope);
|
|
114
|
+
if (total > runtime.selectThreshold) {
|
|
115
|
+
return { options: [], tooMany: true };
|
|
116
|
+
}
|
|
117
|
+
const rows = await runtime.adapter.data.findMany(targetModel, { filter: scope, orderBy: relConfig?.orderBy });
|
|
118
|
+
const options = rows.map((row) => ({
|
|
119
|
+
id: row[primaryKeyOf(targetModel)],
|
|
120
|
+
label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
121
|
+
}));
|
|
122
|
+
return { options, tooMany: false };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { options: [], tooMany: true };
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
// Un ID hors scope retourne null ici → le composant affiche l'ID brut,
|
|
129
|
+
// jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
|
|
130
|
+
// d'un autre tenant).
|
|
131
|
+
const loadActiveLabel = async () => {
|
|
132
|
+
if (activeRawValue === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
const activeId = coerceId(activeRawValue, targetModel);
|
|
135
|
+
try {
|
|
136
|
+
const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: activeId };
|
|
137
|
+
const filter = scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter;
|
|
138
|
+
const row = await runtime.adapter.data.findFirst(targetModel, filter);
|
|
139
|
+
return row ? runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
|
|
146
|
+
return {
|
|
147
|
+
field: fkFieldName,
|
|
148
|
+
label,
|
|
149
|
+
relationField: edge.field,
|
|
150
|
+
targetModel: edge.target,
|
|
151
|
+
options,
|
|
152
|
+
mode: tooMany ? 'raw-id' : options.length <= runtime.filterLinkThreshold ? 'links' : 'select',
|
|
153
|
+
tooMany,
|
|
154
|
+
activeLabel,
|
|
155
|
+
// Une cible exclue/masquée n'a pas de page admin : le chip reste du
|
|
156
|
+
// texte, jamais un lien mort (docs/design §6.4).
|
|
157
|
+
activeHref: activeLabel && runtime.findModel(edge.target)
|
|
158
|
+
? `${runtime.basePath}/${edge.target.toLowerCase()}/${encodeURIComponent(activeRawValue)}`
|
|
159
|
+
: undefined
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
|
|
164
|
+
* d'enregistrements liés côté cible. Résilient : une cible dont le client
|
|
165
|
+
* échoue (mock partiel, modèle absent) retombe sur 0 plutôt que de casser
|
|
166
|
+
* le rendu du formulaire.
|
|
167
|
+
*/
|
|
168
|
+
export async function loadRelatedCounts(runtime, model, currentId, ctx) {
|
|
169
|
+
const edges = [...runtime.relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
|
|
170
|
+
// Un count par relation inverse, indépendants entre eux : en parallèle
|
|
171
|
+
// plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
|
|
172
|
+
const entries = await Promise.all(edges.map(async (edge) => {
|
|
173
|
+
const owning = [...runtime.relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
|
|
174
|
+
if (!owning || owning.unsupported)
|
|
175
|
+
return undefined;
|
|
176
|
+
const scalarName = owning.scalarFields[0];
|
|
177
|
+
const key = `${edge.model}.${edge.field}`;
|
|
178
|
+
const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
|
|
179
|
+
try {
|
|
180
|
+
const count = await runtime.adapter.data.countRecords(targetModel, combinedScope(modelScopeFrom(runtime, targetModel, ctx), { op: 'eq', field: scalarName, value: coerceId(currentId, model) }));
|
|
181
|
+
return [key, count];
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return [key, 0];
|
|
185
|
+
}
|
|
186
|
+
}));
|
|
187
|
+
return new Map(entries.filter((e) => e !== undefined));
|
|
188
|
+
}
|
package/dist/server/router.d.ts
CHANGED
|
@@ -3,4 +3,14 @@ export interface ParsedRoute {
|
|
|
3
3
|
model?: string;
|
|
4
4
|
id?: string;
|
|
5
5
|
}
|
|
6
|
+
export interface RouteEntry {
|
|
7
|
+
pattern: string[];
|
|
8
|
+
view: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const BUILTIN_ROUTES: RouteEntry[];
|
|
11
|
+
export declare function matchRoute(pathname: string, basePath: string, routes: RouteEntry[]): {
|
|
12
|
+
view: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
id?: string;
|
|
15
|
+
};
|
|
6
16
|
export declare function parseRoute(pathname: string, basePath: string): ParsedRoute;
|