sveltekit-admin 0.5.3 → 0.8.1
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 +89 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -1
- package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
- package/dist/server/adapters/drizzle/dataAdapter.js +328 -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 +22 -0
- package/dist/server/adapters/drizzle/index.js +27 -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 +138 -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/handler.d.ts +14 -0
- package/dist/server/adapters/prisma/handler.js +37 -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/retry.d.ts +27 -0
- package/dist/server/adapters/retry.js +53 -0
- package/dist/server/adapters/types.d.ts +86 -0
- package/dist/server/adapters/types.js +1 -0
- 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 +0 -8
- package/dist/server/data.js +0 -41
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +63 -16
- package/dist/server/handler.js +212 -535
- 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/mutations.d.ts +9 -0
- package/dist/server/mutations.js +295 -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/listQuery.d.ts +6 -12
- package/dist/server/query/listQuery.js +31 -53
- 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 +46 -0
- package/dist/server/runtime.js +210 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/types/schema.d.ts +40 -0
- package/dist/server/types/schema.js +8 -0
- package/dist/server/views/Form.svelte +27 -3
- package/dist/server/views/Form.svelte.d.ts +2 -1
- package/dist/server/views/Layout.svelte +27 -2
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +28 -3
- package/dist/server/views/List.svelte.d.ts +2 -2
- package/dist/server/views/RelationCheckboxes.svelte +1 -1
- package/dist/server/views/types.d.ts +8 -0
- package/package.json +40 -17
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { isSensitiveFieldName } from './introspection/parser.js';
|
|
2
|
+
import { buildRelationGraph } from './introspection/relations.js';
|
|
3
|
+
import { primaryKeyOf } from './data.js';
|
|
4
|
+
import { validateListFilterConfig } from './query/filterDetection.js';
|
|
5
|
+
import { isCompositeFilter, isLeafFilter, normalizeScope } from './adapters/filter.js';
|
|
6
|
+
import { toLabel } from './views/html.js';
|
|
7
|
+
import { AdminConfigError } from './errors.js';
|
|
8
|
+
export function scopeFrom(relConfig, ctx) {
|
|
9
|
+
return relConfig?.where ? normalizeScope(relConfig.where(ctx)) : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function listScopeFrom(runtime, model, ctx) {
|
|
12
|
+
const listScope = runtime.config.models?.[model.name]?.listWhere?.(ctx);
|
|
13
|
+
// A scope function returning `{}` (falsy-looking but truthy as
|
|
14
|
+
// an object) would otherwise silently fail OPEN — `{}` composed
|
|
15
|
+
// into an AND matches every row, exactly the opposite of what a
|
|
16
|
+
// caller configuring listWhere expects (real gap found in
|
|
17
|
+
// review: `locals.userId` undefined after a session expires is
|
|
18
|
+
// a realistic way to hit this). Fail loud instead: a scope
|
|
19
|
+
// function is either omitted entirely, or must return at least
|
|
20
|
+
// one condition every time it runs.
|
|
21
|
+
if (listScope && Object.keys(listScope).length === 0) {
|
|
22
|
+
throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.listWhere returned an empty object ({}), ` +
|
|
23
|
+
`which would silently disable list scoping (fail-open). Return undefined/omit the ` +
|
|
24
|
+
`scope entirely if there is genuinely nothing to scope by for this request, or a ` +
|
|
25
|
+
`condition that actually restricts rows otherwise.`);
|
|
26
|
+
}
|
|
27
|
+
return listScope;
|
|
28
|
+
}
|
|
29
|
+
export function modelScopeFrom(runtime, model, ctx) {
|
|
30
|
+
const scope = runtime.config.models?.[model.name]?.scope;
|
|
31
|
+
if (!scope)
|
|
32
|
+
return undefined;
|
|
33
|
+
const raw = scope(ctx);
|
|
34
|
+
if (!raw || (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0)) {
|
|
35
|
+
throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must return a non-empty condition; ` +
|
|
36
|
+
'refusing to fail open.');
|
|
37
|
+
}
|
|
38
|
+
const normalized = normalizeScope(raw);
|
|
39
|
+
const valid = (node) => {
|
|
40
|
+
if (isLeafFilter(node))
|
|
41
|
+
return node.value !== undefined;
|
|
42
|
+
return isCompositeFilter(node) && node.clauses.length > 0 && node.clauses.every(valid);
|
|
43
|
+
};
|
|
44
|
+
if (!valid(normalized)) {
|
|
45
|
+
throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope returned an invalid condition; refusing to fail open.`);
|
|
46
|
+
}
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
/** Extract equality predicates so create can force tenant-owned columns. */
|
|
50
|
+
export function modelScopeValues(runtime, model, ctx) {
|
|
51
|
+
const normalized = modelScopeFrom(runtime, model, ctx);
|
|
52
|
+
if (!normalized)
|
|
53
|
+
return {};
|
|
54
|
+
const values = {};
|
|
55
|
+
const visit = (node) => {
|
|
56
|
+
if (isLeafFilter(node)) {
|
|
57
|
+
if (node.op !== 'eq')
|
|
58
|
+
return false;
|
|
59
|
+
if (node.field in values && values[node.field] !== node.value)
|
|
60
|
+
return false;
|
|
61
|
+
values[node.field] = node.value;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (!isCompositeFilter(node) || node.op === 'or')
|
|
65
|
+
return false;
|
|
66
|
+
return node.clauses.every(visit);
|
|
67
|
+
};
|
|
68
|
+
if (!visit(normalized) || Object.keys(values).length === 0) {
|
|
69
|
+
throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must contain only equality conditions for creation`);
|
|
70
|
+
}
|
|
71
|
+
return values;
|
|
72
|
+
}
|
|
73
|
+
export function createAdminRuntime(config) {
|
|
74
|
+
const { basePath = '/admin', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
|
|
75
|
+
const adapter = config.adapter;
|
|
76
|
+
const introspector = adapter.introspector;
|
|
77
|
+
// Introspect the schema once at startup — same failure handling as before:
|
|
78
|
+
// a broken/missing schema source degrades to "no models known" rather than
|
|
79
|
+
// throwing out of `createAdminHandler` itself.
|
|
80
|
+
let schema = null;
|
|
81
|
+
let relationGraph = null;
|
|
82
|
+
try {
|
|
83
|
+
const introspected = introspector.introspect();
|
|
84
|
+
if (introspected instanceof Promise) {
|
|
85
|
+
throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
|
|
86
|
+
'createAdminHandler only supports synchronous introspection today.');
|
|
87
|
+
}
|
|
88
|
+
schema = introspected;
|
|
89
|
+
relationGraph = buildRelationGraph(schema);
|
|
90
|
+
for (const d of relationGraph.diagnostics) {
|
|
91
|
+
console.warn(`[sveltekit-admin] ${d}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
console.warn('[sveltekit-admin] Could not introspect schema:', e);
|
|
96
|
+
}
|
|
97
|
+
const models = schema?.models.filter((m) => {
|
|
98
|
+
// Exclude explicitly excluded models
|
|
99
|
+
if (exclude.includes(m.name))
|
|
100
|
+
return false;
|
|
101
|
+
// Exclude pivot tables if option is enabled
|
|
102
|
+
if (hidePivotTables && m.isPivotTable)
|
|
103
|
+
return false;
|
|
104
|
+
return true;
|
|
105
|
+
}) || [];
|
|
106
|
+
// Valider `listFilter` au démarrage : une config invalide (champ
|
|
107
|
+
// inexistant, sensible, relation, type non supporté) doit échouer fort
|
|
108
|
+
// ici plutôt que produire silencieusement un filtre mort à chaque rendu
|
|
109
|
+
// de liste (docs/design §8, même politique que le groupe ambigu de
|
|
110
|
+
// relations.ts).
|
|
111
|
+
const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
|
|
112
|
+
for (const m of models) {
|
|
113
|
+
const entries = modelsConfig[m.name]?.listFilter;
|
|
114
|
+
// Non-null par construction : `models` n'existe que si le schéma
|
|
115
|
+
// a été parsé, et le graphe est construit dans la même branche de boot.
|
|
116
|
+
if (entries)
|
|
117
|
+
validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
|
|
118
|
+
}
|
|
119
|
+
const labelOf = (m) => {
|
|
120
|
+
const configured = modelsConfig[m.name]?.label;
|
|
121
|
+
if (configured)
|
|
122
|
+
return configured;
|
|
123
|
+
const label = toLabel(m.name);
|
|
124
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
125
|
+
};
|
|
126
|
+
const modelList = models.map((m) => ({ name: m.name, label: labelOf(m) }));
|
|
127
|
+
const findModel = (name) => models.find((m) => m.name.toLowerCase() === name?.toLowerCase());
|
|
128
|
+
const viewModel = (m) => ({
|
|
129
|
+
name: m.name,
|
|
130
|
+
label: labelOf(m),
|
|
131
|
+
fields: m.fields,
|
|
132
|
+
primaryKey: primaryKeyOf(m),
|
|
133
|
+
// Non-null par construction : `m` vient toujours de `models`,
|
|
134
|
+
// dérivé du schéma qu'on vient de parser avec succès.
|
|
135
|
+
relationGraph: relationGraph
|
|
136
|
+
});
|
|
137
|
+
const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
|
|
138
|
+
const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
|
|
139
|
+
const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
|
|
140
|
+
'name', 'title', 'label', 'email', 'username', 'slug'
|
|
141
|
+
];
|
|
142
|
+
/**
|
|
143
|
+
* Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
|
|
144
|
+
* champ scalaire non-liste, non-relation, de type filtrable
|
|
145
|
+
* (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
|
|
146
|
+
* Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
|
|
147
|
+
* Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
|
|
148
|
+
* l'affichage : le champ reste un oracle de confirmation de valeur via
|
|
149
|
+
* `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
|
|
150
|
+
* ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
|
|
151
|
+
* de sensibilité sont deux sources distinctes, toutes deux doivent
|
|
152
|
+
* fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
|
|
153
|
+
* partagé, sinon divergence garantie"). Défense en profondeur :
|
|
154
|
+
* `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
|
|
155
|
+
* la première passe et la seule à connaître la config `hidden`.
|
|
156
|
+
*/
|
|
157
|
+
const resolveFilterableFields = (model) => {
|
|
158
|
+
const hidden = hiddenFieldsOf(model);
|
|
159
|
+
const out = new Set();
|
|
160
|
+
for (const f of model.fields) {
|
|
161
|
+
if (f.relation || f.isList)
|
|
162
|
+
continue;
|
|
163
|
+
if (['Json', 'Bytes'].includes(f.type))
|
|
164
|
+
continue;
|
|
165
|
+
if (isSensitiveFieldName(f.name))
|
|
166
|
+
continue;
|
|
167
|
+
if (hidden.has(f.name))
|
|
168
|
+
continue;
|
|
169
|
+
out.add(f.name);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Résout le label BRUT (non échappé) d'une ligne : premier champ String
|
|
175
|
+
* candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
|
|
176
|
+
* Déterministe. Svelte échappe automatiquement à l'interpolation dans les
|
|
177
|
+
* composants — pas besoin d'échapper ici.
|
|
178
|
+
*/
|
|
179
|
+
const resolveLabel = (targetModel, row, labelTemplate) => {
|
|
180
|
+
if (labelTemplate) {
|
|
181
|
+
return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
|
|
182
|
+
}
|
|
183
|
+
for (const candidate of labelFieldCandidates) {
|
|
184
|
+
const field = targetModel.fields.find((f) => f.name === candidate);
|
|
185
|
+
if (field && field.type === 'String' && row[candidate] != null) {
|
|
186
|
+
return String(row[candidate]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return String(row[primaryKeyOf(targetModel)]);
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
adapter,
|
|
193
|
+
schema,
|
|
194
|
+
relationGraph,
|
|
195
|
+
models,
|
|
196
|
+
modelList,
|
|
197
|
+
config,
|
|
198
|
+
basePath,
|
|
199
|
+
perPage: 20,
|
|
200
|
+
selectThreshold,
|
|
201
|
+
filterLinkThreshold,
|
|
202
|
+
labelFieldCandidates,
|
|
203
|
+
findModel,
|
|
204
|
+
labelOf,
|
|
205
|
+
hiddenFieldsOf,
|
|
206
|
+
viewModel,
|
|
207
|
+
resolveLabel,
|
|
208
|
+
resolveFilterableFields
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
|
|
3
|
+
* over `AdminRuntime`.
|
|
4
|
+
*/
|
|
5
|
+
import { type AdminRuntime } from './runtime.js';
|
|
6
|
+
/**
|
|
7
|
+
* Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
|
|
8
|
+
* Sert les options d'une relation to-one-owning ou m2m en JSON
|
|
9
|
+
* paginé — la voie prévue pour un futur widget autocomplete côté client
|
|
10
|
+
* quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
|
|
11
|
+
* de scoping configuré sur la relation, comme le select et la validation
|
|
12
|
+
* POST : même garantie anti-IDOR sur les trois chemins.
|
|
13
|
+
*/
|
|
14
|
+
export declare function handleSearch(runtime: AdminRuntime, event: any): Promise<Response>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
|
|
3
|
+
* over `AdminRuntime`.
|
|
4
|
+
*/
|
|
5
|
+
import { primaryKeyOf, paginate } from './data.js';
|
|
6
|
+
import { scopeFrom, modelScopeFrom } from './runtime.js';
|
|
7
|
+
/**
|
|
8
|
+
* Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
|
|
9
|
+
* Sert les options d'une relation to-one-owning ou m2m en JSON
|
|
10
|
+
* paginé — la voie prévue pour un futur widget autocomplete côté client
|
|
11
|
+
* quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
|
|
12
|
+
* de scoping configuré sur la relation, comme le select et la validation
|
|
13
|
+
* POST : même garantie anti-IDOR sur les trois chemins.
|
|
14
|
+
*/
|
|
15
|
+
export async function handleSearch(runtime, event) {
|
|
16
|
+
const modelsConfig = runtime.config.models ?? {};
|
|
17
|
+
const relParam = event.url.searchParams.get('rel') ?? '';
|
|
18
|
+
const [modelName, fieldName] = relParam.split('.');
|
|
19
|
+
const q = event.url.searchParams.get('q') ?? '';
|
|
20
|
+
const { page } = paginate(event.url.searchParams.get('page'), runtime.perPage);
|
|
21
|
+
const model = runtime.findModel(modelName);
|
|
22
|
+
const edge = model && runtime.relationGraph
|
|
23
|
+
? runtime.relationGraph.edges.get(`${model.name}.${fieldName}`)
|
|
24
|
+
: undefined;
|
|
25
|
+
if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
|
|
26
|
+
return new Response(JSON.stringify({ error: 'unknown relation' }), {
|
|
27
|
+
status: 404,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
|
|
32
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
33
|
+
const configWhere = scopeFrom(relConfig, { locals: event.locals });
|
|
34
|
+
const modelWhere = modelScopeFrom(runtime, targetModel, { locals: event.locals });
|
|
35
|
+
// Recherche sur le premier champ String candidat du modèle cible — le
|
|
36
|
+
// même champ que celui utilisé pour construire le label par défaut.
|
|
37
|
+
const searchField = runtime.labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
|
|
38
|
+
// `_search` must stay case-sensitive on every adapter/provider. A
|
|
39
|
+
// `{ op: 'contains' }` leaf would pick up the adapter-wide
|
|
40
|
+
// `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
|
|
41
|
+
// `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
|
|
42
|
+
// no case-folding — same observable Prisma behavior as the previous
|
|
43
|
+
// opaque `{ [field]: { contains: q } }` pass-through.
|
|
44
|
+
const containsFilter = q && searchField
|
|
45
|
+
? { op: 'containsExact', field: searchField, value: q }
|
|
46
|
+
: undefined;
|
|
47
|
+
const searchClauses = [modelWhere, configWhere, containsFilter].filter(Boolean);
|
|
48
|
+
const searchFilter = searchClauses.length > 1
|
|
49
|
+
? { op: 'and', clauses: searchClauses }
|
|
50
|
+
: searchClauses[0];
|
|
51
|
+
try {
|
|
52
|
+
// Count + fetch are independent reads — run them in parallel (as this
|
|
53
|
+
// endpoint always has, pre-refactor) rather than doubling latency with
|
|
54
|
+
// two sequential awaits.
|
|
55
|
+
const [total, rows] = await Promise.all([
|
|
56
|
+
runtime.adapter.data.countRecords(targetModel, searchFilter),
|
|
57
|
+
runtime.adapter.data.findMany(targetModel, {
|
|
58
|
+
filter: searchFilter,
|
|
59
|
+
orderBy: relConfig?.orderBy,
|
|
60
|
+
skip: (page - 1) * runtime.perPage,
|
|
61
|
+
take: runtime.perPage
|
|
62
|
+
})
|
|
63
|
+
]);
|
|
64
|
+
const options = rows.map((row) => ({
|
|
65
|
+
id: row[primaryKeyOf(targetModel)],
|
|
66
|
+
label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
67
|
+
}));
|
|
68
|
+
return new Response(JSON.stringify({ options, total, page }), {
|
|
69
|
+
headers: { 'Content-Type': 'application/json' }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return new Response(JSON.stringify({ error: 'search failed' }), {
|
|
74
|
+
status: 500,
|
|
75
|
+
headers: { 'Content-Type': 'application/json' }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic schema shapes shared by every schema-source adapter (Prisma today,
|
|
3
|
+
* others later). Deliberately identical in shape to what `introspection/
|
|
4
|
+
* parser.ts` has always produced — this file is a rename of that shape, not
|
|
5
|
+
* a redesign of it. `PrismaSchema`/`PrismaModel`/`PrismaField` in parser.ts
|
|
6
|
+
* become aliases of these.
|
|
7
|
+
*/
|
|
8
|
+
export interface Field {
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
isRequired: boolean;
|
|
12
|
+
isList: boolean;
|
|
13
|
+
isUnique: boolean;
|
|
14
|
+
isId: boolean;
|
|
15
|
+
isUpdatedAt: boolean;
|
|
16
|
+
isCreatedAt: boolean;
|
|
17
|
+
hasDefault: boolean;
|
|
18
|
+
defaultValue?: string;
|
|
19
|
+
/** true si `type` correspond à un `enum` déclaré dans le même schéma. */
|
|
20
|
+
isEnum?: boolean;
|
|
21
|
+
relation?: {
|
|
22
|
+
name?: string;
|
|
23
|
+
model: string;
|
|
24
|
+
fields?: string[];
|
|
25
|
+
references?: string[];
|
|
26
|
+
};
|
|
27
|
+
documentation?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Model {
|
|
30
|
+
name: string;
|
|
31
|
+
fields: Field[];
|
|
32
|
+
documentation?: string;
|
|
33
|
+
primaryKey?: string;
|
|
34
|
+
isPivotTable?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface Schema {
|
|
37
|
+
models: Model[];
|
|
38
|
+
enums: Map<string, string[]>;
|
|
39
|
+
provider?: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic schema shapes shared by every schema-source adapter (Prisma today,
|
|
3
|
+
* others later). Deliberately identical in shape to what `introspection/
|
|
4
|
+
* parser.ts` has always produced — this file is a rename of that shape, not
|
|
5
|
+
* a redesign of it. `PrismaSchema`/`PrismaModel`/`PrismaField` in parser.ts
|
|
6
|
+
* become aliases of these.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
3
|
-
import type { ViewModel } from './types.js';
|
|
3
|
+
import type { RecordAction, ViewModel } from './types.js';
|
|
4
4
|
import FieldInput from './FieldInput.svelte';
|
|
5
5
|
import RelationSelect from './RelationSelect.svelte';
|
|
6
6
|
import RelationCheckboxes from './RelationCheckboxes.svelte';
|
|
7
7
|
import RelatedBlock from './RelatedBlock.svelte';
|
|
8
|
+
import { escapeHtml } from './html.js';
|
|
8
9
|
|
|
9
10
|
let {
|
|
10
11
|
mode,
|
|
11
12
|
model,
|
|
12
13
|
basePath,
|
|
13
14
|
config,
|
|
14
|
-
item
|
|
15
|
+
item,
|
|
16
|
+
recordActions = []
|
|
15
17
|
}: {
|
|
16
18
|
mode: 'create' | 'edit';
|
|
17
19
|
model: ViewModel;
|
|
18
20
|
basePath: string;
|
|
19
21
|
config: AdminHandlerConfig;
|
|
20
22
|
item?: any;
|
|
23
|
+
recordActions?: RecordAction[];
|
|
21
24
|
} = $props();
|
|
22
25
|
|
|
23
26
|
const modelConfig = $derived(config.models?.[model.name] || {});
|
|
@@ -69,7 +72,7 @@
|
|
|
69
72
|
? [...model.relationGraph.edges.values()].filter(
|
|
70
73
|
(e) =>
|
|
71
74
|
e.model === model.name &&
|
|
72
|
-
e.kind === 'm2m
|
|
75
|
+
e.kind === 'm2m' &&
|
|
73
76
|
!e.unsupported &&
|
|
74
77
|
!hidden.includes(e.field) &&
|
|
75
78
|
model.relationOptions?.has(`${e.model}.${e.field}`)
|
|
@@ -77,6 +80,24 @@
|
|
|
77
80
|
: []
|
|
78
81
|
);
|
|
79
82
|
|
|
83
|
+
// Built as a single string (rather than {#if}/{#each}) so an empty/create-mode
|
|
84
|
+
// render stays a single @html call: Svelte 5's SSR wraps every {#if}/{#each} node
|
|
85
|
+
// in its own hydration-boundary comment regardless of the branch/array taken, so
|
|
86
|
+
// nesting recordActions in its own control-flow blocks would add bytes to every
|
|
87
|
+
// edit-form render even when recordActions is []. `label` and `href` are both
|
|
88
|
+
// escaped manually since this goes through @html instead of Svelte's
|
|
89
|
+
// auto-escaped text/attributes.
|
|
90
|
+
const recordActionsHtml = $derived(
|
|
91
|
+
mode === 'edit' && recordActions.length > 0
|
|
92
|
+
? `<div class="ska-record-actions">${recordActions
|
|
93
|
+
.map(
|
|
94
|
+
(action) =>
|
|
95
|
+
`<a href="${escapeHtml(action.href)}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
|
|
96
|
+
)
|
|
97
|
+
.join('')}</div>`
|
|
98
|
+
: ''
|
|
99
|
+
);
|
|
100
|
+
|
|
80
101
|
const inverseEdges = $derived(
|
|
81
102
|
model.relationGraph
|
|
82
103
|
? [...model.relationGraph.edges.values()].filter(
|
|
@@ -92,6 +113,9 @@
|
|
|
92
113
|
<p class="ska-subtitle">ID: {item[model.primaryKey]}</p>
|
|
93
114
|
{/if}
|
|
94
115
|
|
|
116
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and action.href via escapeHtml -->
|
|
117
|
+
{@html recordActionsHtml}
|
|
118
|
+
|
|
95
119
|
<div class="ska-card">
|
|
96
120
|
<form method="POST" class="ska-form">
|
|
97
121
|
<input type="hidden" name="_action" value={mode === 'create' ? 'create' : 'update'} />
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
2
|
-
import type { ViewModel } from './types.js';
|
|
2
|
+
import type { RecordAction, ViewModel } from './types.js';
|
|
3
3
|
type $$ComponentProps = {
|
|
4
4
|
mode: 'create' | 'edit';
|
|
5
5
|
model: ViewModel;
|
|
6
6
|
basePath: string;
|
|
7
7
|
config: AdminHandlerConfig;
|
|
8
8
|
item?: any;
|
|
9
|
+
recordActions?: RecordAction[];
|
|
9
10
|
};
|
|
10
11
|
declare const Form: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
12
|
type Form = ReturnType<typeof Form>;
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
content,
|
|
7
7
|
config,
|
|
8
8
|
modelList,
|
|
9
|
-
currentModel
|
|
9
|
+
currentModel,
|
|
10
|
+
extraStyles = '',
|
|
11
|
+
extraScripts = ''
|
|
10
12
|
}: {
|
|
11
13
|
content: string;
|
|
12
14
|
config: AdminHandlerConfig;
|
|
13
15
|
modelList: Array<{ name: string; label: string }>;
|
|
14
16
|
currentModel?: string;
|
|
17
|
+
extraStyles?: string;
|
|
18
|
+
extraScripts?: string;
|
|
15
19
|
} = $props();
|
|
16
20
|
|
|
17
21
|
const branding = $derived(config.branding ?? {});
|
|
@@ -21,6 +25,25 @@
|
|
|
21
25
|
// No button at all if `logout` isn't configured — an admin that never
|
|
22
26
|
// opted into this option looks exactly as it did before it existed.
|
|
23
27
|
const showLogout = $derived(Boolean(config.logout));
|
|
28
|
+
|
|
29
|
+
// extraStyles is concatenated into the SAME @html expression as the theme <style>
|
|
30
|
+
// below, rather than a sibling {#if}/{@html} block: Svelte 5's SSR unconditionally
|
|
31
|
+
// wraps every {#if}/{#each}/{@html} node in its own hydration-boundary comment, even
|
|
32
|
+
// for a false/empty branch (see svelte/internal/server's `html()` helper) — a sibling
|
|
33
|
+
// block would add bytes to every render regardless of extraStyles being set.
|
|
34
|
+
// Concatenating keeps this ONE @html call, byte-identical to the pre-plugin-slots
|
|
35
|
+
// template when extraStyles is ''. extraScripts (bottom of <body>) has no such
|
|
36
|
+
// pre-existing @html call to fold into, so it stays its own @html — the smallest
|
|
37
|
+
// achievable footprint, though it still adds a fixed hydration-boundary comment
|
|
38
|
+
// pair even when empty (see task-6-report.md fix-round-1 notes).
|
|
39
|
+
//
|
|
40
|
+
// Built as a $derived here (rather than a nested template literal inline in the
|
|
41
|
+
// markup below) so tooling that tag-sniffs {@html} expressions for literal
|
|
42
|
+
// <style>/<script> text doesn't misparse the nested backticks.
|
|
43
|
+
const headStyleHtml = $derived(
|
|
44
|
+
`<style>${styles(primaryColor)}</style>${extraStyles ? `<style>${extraStyles}</style>` : ''}`
|
|
45
|
+
);
|
|
46
|
+
const bodyScriptHtml = $derived(extraScripts ? '<script>' + extraScripts + '</scr' + 'ipt>' : '');
|
|
24
47
|
</script>
|
|
25
48
|
|
|
26
49
|
<!doctype html>
|
|
@@ -31,7 +54,7 @@
|
|
|
31
54
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
32
55
|
<title>{title}</title>
|
|
33
56
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS injected as raw text; a literal <style> block can't take a dynamic value; primaryColor is developer-supplied config, not request/database data, and this raw interpolation is unchanged from the original layout.ts implementation, not a new injection point introduced by this migration -->
|
|
34
|
-
{@html
|
|
57
|
+
{@html headStyleHtml}
|
|
35
58
|
</head>
|
|
36
59
|
<!-- eslint-disable-next-line svelte/no-raw-special-elements -- server-only full-document template, never mounted client-side -->
|
|
37
60
|
<body>
|
|
@@ -74,5 +97,7 @@
|
|
|
74
97
|
{@html content}
|
|
75
98
|
</main>
|
|
76
99
|
</div>
|
|
100
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- plugin JS is developer-supplied, same trust as branding.primaryColor -->
|
|
101
|
+
{@html bodyScriptHtml}
|
|
77
102
|
</body>
|
|
78
103
|
</html>
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
3
|
-
import type { ViewModel } from './types.js';
|
|
3
|
+
import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
|
|
4
4
|
import type { ListQuery } from '../query/listQuery.js';
|
|
5
5
|
import type { ResolvedFilterField } from '../query/filterDetection.js';
|
|
6
6
|
import { DATETIME_PRESETS } from '../query/filterDetection.js';
|
|
7
|
-
import type { FkFilterMeta } from './types.js';
|
|
8
7
|
import { getDisplayFields } from '../introspection/parser.js';
|
|
9
8
|
import { buildListUrl, hiddenParams } from '../query/urls.js';
|
|
10
9
|
import { escapeHtml, toLabel, formatValue } from './html.js';
|
|
@@ -19,7 +18,8 @@
|
|
|
19
18
|
query,
|
|
20
19
|
currentUrl,
|
|
21
20
|
listFilters,
|
|
22
|
-
fkFilterMeta
|
|
21
|
+
fkFilterMeta,
|
|
22
|
+
recordActions = []
|
|
23
23
|
}: {
|
|
24
24
|
model: ViewModel;
|
|
25
25
|
items: any[];
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
listFilters?: ResolvedFilterField[];
|
|
35
35
|
/** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
|
|
36
36
|
fkFilterMeta?: Map<string, FkFilterMeta>;
|
|
37
|
+
recordActions?: ListRecordAction[];
|
|
37
38
|
} = $props();
|
|
38
39
|
|
|
39
40
|
const modelConfig = $derived(config.models?.[model.name] || {});
|
|
@@ -126,6 +127,28 @@
|
|
|
126
127
|
* pour un champ sensible que pour un champ inconnu (§0.a, §5.4) — ne
|
|
127
128
|
* jamais dire "champ interdit", ça confirmerait son existence.
|
|
128
129
|
*/
|
|
130
|
+
// A per-row function (rather than {#each} in the markup) so an empty recordActions
|
|
131
|
+
// keeps this to the smallest possible footprint: Svelte 5's SSR wraps every
|
|
132
|
+
// {#each}/{@html} node in its own hydration-boundary comment regardless of the
|
|
133
|
+
// array's length/content (verified empirically — even {@html ''} still emits
|
|
134
|
+
// `<!--hash--><!---->`), so there is no template-level construct that renders zero
|
|
135
|
+
// bytes for an empty array here. Folding this into the pre-existing delete-form
|
|
136
|
+
// {@html} call (right below) was considered and rejected: recordActions must render
|
|
137
|
+
// *before* Edit (see list.test.ts "rend le lien avant Edit"), but the delete form's
|
|
138
|
+
// pre-existing {@html} — and thus its hydration marker — sits *after* Edit, so
|
|
139
|
+
// reusing it would either reorder Edit/recordActions or move the marker in front of
|
|
140
|
+
// Edit for every row, not just when recordActions is non-empty. Neither is
|
|
141
|
+
// byte-identical to the pre-recordActions baseline (see task-6-report.md fix-round-1
|
|
142
|
+
// notes). `action.label` and `hrefFor`'s return value are both escaped manually
|
|
143
|
+
// since this goes through @html instead of Svelte's auto-escaped text/attributes.
|
|
144
|
+
const recordActionsHtml = (id: string | number) =>
|
|
145
|
+
recordActions
|
|
146
|
+
.map(
|
|
147
|
+
(action) =>
|
|
148
|
+
`<a href="${escapeHtml(action.hrefFor(id))}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
|
|
149
|
+
)
|
|
150
|
+
.join('');
|
|
151
|
+
|
|
129
152
|
const ignoredMessages = $derived.by(() => {
|
|
130
153
|
return (query?.ignored ?? []).map((entry) => {
|
|
131
154
|
// `param` est soit `f.<field>` / `f.<field>__<op>` (nouveau format),
|
|
@@ -217,6 +240,8 @@
|
|
|
217
240
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- formatValue already escapes string values itself and returns a literal <span> only for null/undefined -->
|
|
218
241
|
{#each displayFields as f (f.name)}<td>{@html formatValue(item[f.name], f.type)}</td>{/each}
|
|
219
242
|
<td class="ska-table__actions">
|
|
243
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and hrefFor's return value via escapeHtml -->
|
|
244
|
+
{@html recordActionsHtml(item[model.primaryKey])}
|
|
220
245
|
<a href="{listPath}/{item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
|
|
221
246
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Svelte 5 rejects a literal onsubmit string as an event attribute; the PK is escaped manually here since it can't go through Svelte's native attribute escaping; the whole form (not just onsubmit) is rendered as raw HTML because there's no native-Svelte way to attach a plain inline onsubmit="..." string attribute at all in Svelte 5 templates, so the whole element had to be raw text to preserve the exact prior confirm-dialog behavior in a page that's never hydrated by a Svelte runtime -->
|
|
222
247
|
{@html `<form method="POST" action="${listPath}/${escapeHtml(String(item[model.primaryKey]))}" style="display:inline" onsubmit="return confirm('Delete this item?')"><input type="hidden" name="_action" value="delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button></form>`}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
2
|
-
import type { ViewModel } from './types.js';
|
|
2
|
+
import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
|
|
3
3
|
import type { ListQuery } from '../query/listQuery.js';
|
|
4
4
|
import type { ResolvedFilterField } from '../query/filterDetection.js';
|
|
5
|
-
import type { FkFilterMeta } from './types.js';
|
|
6
5
|
type $$ComponentProps = {
|
|
7
6
|
model: ViewModel;
|
|
8
7
|
items: any[];
|
|
@@ -21,6 +20,7 @@ type $$ComponentProps = {
|
|
|
21
20
|
listFilters?: ResolvedFilterField[];
|
|
22
21
|
/** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
|
|
23
22
|
fkFilterMeta?: Map<string, FkFilterMeta>;
|
|
23
|
+
recordActions?: ListRecordAction[];
|
|
24
24
|
};
|
|
25
25
|
declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
26
26
|
type List = ReturnType<typeof List>;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
</script>
|
|
18
18
|
|
|
19
19
|
<!--
|
|
20
|
-
Fieldset de checkboxes pour une arête m2m
|
|
20
|
+
Fieldset de checkboxes pour une arête m2m.
|
|
21
21
|
|
|
22
22
|
Nommage `__rel__<field>` pour les valeurs cochées et un hidden sentinelle
|
|
23
23
|
`__rel_present__<field>` toujours émis : en HTML, zéro checkbox cochée
|
|
@@ -22,6 +22,14 @@ export interface ViewModel {
|
|
|
22
22
|
/** Compteurs des relations inverses (1-N, 1-1), indexés par "Model.field" */
|
|
23
23
|
relatedCounts?: Map<string, number>;
|
|
24
24
|
}
|
|
25
|
+
export interface RecordAction {
|
|
26
|
+
label: string;
|
|
27
|
+
href: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ListRecordAction {
|
|
30
|
+
label: string;
|
|
31
|
+
hrefFor: (id: string | number) => string;
|
|
32
|
+
}
|
|
25
33
|
/**
|
|
26
34
|
* Résolution async d'un filtre FK (kind 'fk' dans ResolvedFilterField) :
|
|
27
35
|
* options scopées pour la sidebar + label du chip actif scopé lui aussi
|