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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* string) becomes the Prisma operator key.
|
|
13
13
|
*/
|
|
14
14
|
import { isSensitiveFieldName } from '../introspection/parser.js';
|
|
15
|
+
import { normalizeScope } from '../adapters/filter.js';
|
|
15
16
|
/** Max length accepted for the free-text search term. Longer input is truncated. */
|
|
16
17
|
const MAX_SEARCH_LENGTH = 200;
|
|
17
18
|
/**
|
|
@@ -351,92 +352,69 @@ export function parseListQuery(searchParams, model, enums, searchFields, filtera
|
|
|
351
352
|
}
|
|
352
353
|
function clauseOf(filter) {
|
|
353
354
|
if (filter.op === 'gte' && filter.value && typeof filter.value === 'object' && 'gte' in filter.value) {
|
|
354
|
-
// Date shortcut carrying both bounds (see parseOneFilter's DateTime branch)
|
|
355
|
+
// Date shortcut carrying both bounds (see parseOneFilter's DateTime branch) —
|
|
356
|
+
// becomes two leaf clauses, re-merged by the Prisma filterCompiler.
|
|
355
357
|
const range = filter.value;
|
|
356
|
-
return
|
|
358
|
+
return [
|
|
359
|
+
{ op: 'gte', field: filter.field, value: range.gte },
|
|
360
|
+
{ op: 'lt', field: filter.field, value: range.lt }
|
|
361
|
+
];
|
|
357
362
|
}
|
|
358
363
|
if (filter.op === 'isnull') {
|
|
359
|
-
return {
|
|
364
|
+
return [{ op: filter.value ? 'isNull' : 'isNotNull', field: filter.field }];
|
|
360
365
|
}
|
|
361
366
|
if (filter.op === 'equals') {
|
|
362
|
-
return {
|
|
367
|
+
return [{ op: 'eq', field: filter.field, value: filter.value }];
|
|
363
368
|
}
|
|
364
|
-
return {
|
|
369
|
+
return [{ op: filter.op, field: filter.field, value: filter.value }];
|
|
365
370
|
}
|
|
366
371
|
/**
|
|
367
|
-
* Compose the final
|
|
368
|
-
* NEVER a spread
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
* clauses on the same field inside `AND` intersect; they never merge.
|
|
372
|
-
*
|
|
373
|
-
* Returns `undefined` when nothing is active, so the query shape sent to
|
|
374
|
-
* Prisma is byte-for-byte identical to today's unfiltered call — no
|
|
375
|
-
* regression on existing snapshots/assertions.
|
|
372
|
+
* Compose the final generic `Filter`: `and: [scope, ...filters, {or: search}]`.
|
|
373
|
+
* NEVER a spread. Flat `{ tenantId: 1 }` scopes become `eq` leaves via
|
|
374
|
+
* `normalizeScope`; nested Prisma where objects stay opaque for the Prisma
|
|
375
|
+
* compiler. Drizzle's compiler throws on those opaques.
|
|
376
376
|
*/
|
|
377
377
|
export function buildWhere(query, scope, caseInsensitiveSearch, model) {
|
|
378
378
|
const and = [];
|
|
379
|
-
|
|
380
|
-
|
|
379
|
+
const normalized = normalizeScope(scope);
|
|
380
|
+
if (normalized)
|
|
381
|
+
and.push(normalized);
|
|
381
382
|
for (const f of query.filters)
|
|
382
|
-
and.push(clauseOf(f));
|
|
383
|
+
and.push(...clauseOf(f));
|
|
383
384
|
if (query.q && query.searchFields.length > 0) {
|
|
384
385
|
const or = [];
|
|
385
386
|
for (const fieldName of query.searchFields) {
|
|
386
387
|
const field = model.fields.find((f) => f.name === fieldName);
|
|
387
|
-
const clause = searchClauseFor(field, query.q
|
|
388
|
+
const clause = searchClauseFor(field, fieldName, query.q);
|
|
388
389
|
if (clause)
|
|
389
|
-
or.push(
|
|
390
|
+
or.push(clause);
|
|
390
391
|
}
|
|
391
|
-
// Never emit `{OR: []}` — in Prisma that matches nothing, which would
|
|
392
|
-
// silently turn "no searchable field" (or "every clause omitted", §2.4)
|
|
393
|
-
// into "empty result". A no-op search must add nothing to the where.
|
|
394
392
|
if (or.length > 0)
|
|
395
|
-
and.push({
|
|
393
|
+
and.push({ op: 'or', clauses: or });
|
|
396
394
|
}
|
|
397
395
|
if (and.length === 0)
|
|
398
396
|
return undefined;
|
|
399
397
|
if (and.length === 1)
|
|
400
398
|
return and[0];
|
|
401
|
-
return {
|
|
399
|
+
return { op: 'and', clauses: and };
|
|
402
400
|
}
|
|
403
401
|
/**
|
|
404
|
-
* The per-field-type clause for a `searchFields` entry (§2.4)
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* nearly every Prisma schema and are exactly what §2.3's "a title, an
|
|
411
|
-
* email" example means by free-text search. `@unique` alone is NOT a
|
|
412
|
-
* reason to switch to `equals` — only `@id` is).
|
|
413
|
-
* - other String (including @unique) -> `contains` (+ `mode:
|
|
414
|
-
* 'insensitive'` when the provider supports it).
|
|
415
|
-
* - Int/BigInt/Float/Decimal -> `equals` if `q` coerces to that type,
|
|
416
|
-
* otherwise the clause is OMITTED — never `contains` on a numeric
|
|
417
|
-
* column, which Prisma rejects with a hard error (`Unknown argument
|
|
418
|
-
* contains`), turning any legitimate `?q=` into a 500 (§10's known
|
|
419
|
-
* trap, discovered via review — the original implementation searched
|
|
420
|
-
* this exactly wrong).
|
|
421
|
-
* - anything else (enum, Boolean, DateTime, relation, Json/Bytes):
|
|
422
|
-
* omitted. `resolveSearchFields`'s auto heuristic never proposes these,
|
|
423
|
-
* but explicit `searchFields` config isn't type-checked against §2.4 at
|
|
424
|
-
* boot (only against `isFilterableFieldType`), so this is reached in
|
|
425
|
-
* practice for a misconfigured field — degrading to "omitted" here
|
|
426
|
-
* keeps the guarantee that a legitimate URL never 500s, without adding
|
|
427
|
-
* a boot-time validation pass this design doc doesn't ask for.
|
|
402
|
+
* The per-field-type clause for a `searchFields` entry (§2.4) — same rules as
|
|
403
|
+
* before (String @id -> eq, other String -> contains, numeric -> eq if
|
|
404
|
+
* coercible else omitted, anything else omitted). `caseInsensitiveSearch` no
|
|
405
|
+
* longer lives here: the generic `Filter` doesn't carry a case-sensitivity
|
|
406
|
+
* flag, `filterCompiler.ts` decides whether to add `mode: 'insensitive'` from
|
|
407
|
+
* the same boolean at the handler.ts call site instead.
|
|
428
408
|
*/
|
|
429
|
-
function searchClauseFor(field,
|
|
409
|
+
function searchClauseFor(field, fieldName, q) {
|
|
430
410
|
if (!field)
|
|
431
411
|
return undefined;
|
|
432
412
|
if (field.type === 'String') {
|
|
433
|
-
|
|
434
|
-
return { equals: q };
|
|
435
|
-
return caseInsensitiveSearch ? { contains: q, mode: 'insensitive' } : { contains: q };
|
|
413
|
+
return field.isId ? { op: 'eq', field: fieldName, value: q } : { op: 'contains', field: fieldName, value: q };
|
|
436
414
|
}
|
|
437
415
|
if (['Int', 'BigInt', 'Float', 'Decimal'].includes(field.type)) {
|
|
438
416
|
const coerced = coerceValue(field, 'equals', q);
|
|
439
|
-
return coerced === undefined ? undefined : {
|
|
417
|
+
return coerced === undefined ? undefined : { op: 'eq', field: fieldName, value: coerced };
|
|
440
418
|
}
|
|
441
419
|
return undefined;
|
|
442
420
|
}
|
|
@@ -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;
|
package/dist/server/router.js
CHANGED
|
@@ -1,27 +1,50 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const BUILTIN_ROUTES = [
|
|
2
|
+
{ pattern: [], view: 'dashboard' },
|
|
3
|
+
{ pattern: ['_search'], view: 'search' },
|
|
4
|
+
{ pattern: ['_logout'], view: 'logout' },
|
|
5
|
+
{ pattern: [':model', 'new'], view: 'create' },
|
|
6
|
+
{ pattern: [':model', ':id'], view: 'edit' },
|
|
7
|
+
{ pattern: [':model'], view: 'list' }
|
|
8
|
+
];
|
|
9
|
+
function relativeSegments(pathname, basePath) {
|
|
2
10
|
// Le `replace` n'est PAS redondant avec le `filter(Boolean)` plus bas : il est ce
|
|
3
11
|
// qui fait que `/admin/` et `/admin///` donnent un `path` vide, donc le dashboard.
|
|
4
12
|
// Sans lui, `path` vaudrait '/' — truthy — et le chemin tomberait sur `notFound`.
|
|
5
13
|
const path = pathname.slice(basePath.length).replace(/^\/+|\/+$/g, '');
|
|
6
|
-
if (!path)
|
|
7
|
-
return
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
if (!path)
|
|
15
|
+
return [];
|
|
16
|
+
return path.split('/').filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
export function matchRoute(pathname, basePath, routes) {
|
|
19
|
+
const segments = relativeSegments(pathname, basePath);
|
|
20
|
+
for (const route of routes) {
|
|
21
|
+
if (route.pattern.length !== segments.length)
|
|
22
|
+
continue;
|
|
23
|
+
const captured = {};
|
|
24
|
+
let ok = true;
|
|
25
|
+
for (let i = 0; i < route.pattern.length; i++) {
|
|
26
|
+
const token = route.pattern[i];
|
|
27
|
+
const seg = segments[i];
|
|
28
|
+
if (token === ':model') {
|
|
29
|
+
captured.model = seg;
|
|
30
|
+
}
|
|
31
|
+
else if (token === ':id') {
|
|
32
|
+
captured.id = seg;
|
|
33
|
+
}
|
|
34
|
+
else if (token !== seg) {
|
|
35
|
+
ok = false;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
22
38
|
}
|
|
23
|
-
|
|
39
|
+
if (ok)
|
|
40
|
+
return { view: route.view, ...captured };
|
|
24
41
|
}
|
|
25
|
-
// 3 segments ou plus : aucune vue ne correspond.
|
|
26
42
|
return { view: 'notFound' };
|
|
27
43
|
}
|
|
44
|
+
// Builtins-only helper — NOT the handler's dispatch path. `handler.ts` calls
|
|
45
|
+
// `matchRoute` directly with `[...pluginRoutes, ...BUILTIN_ROUTES]` so plugin
|
|
46
|
+
// patterns are considered first; this function stays around for tests and
|
|
47
|
+
// any caller that only cares about the builtin route table.
|
|
48
|
+
export function parseRoute(pathname, basePath) {
|
|
49
|
+
return matchRoute(pathname, basePath, BUILTIN_ROUTES);
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Schema, Model } from './types/schema.js';
|
|
2
|
+
import { type RelationGraph } from './introspection/relations.js';
|
|
3
|
+
import type { ViewModel } from './views/types.js';
|
|
4
|
+
import type { DataAdapter, SchemaIntrospector, Filter } from './adapters/types.js';
|
|
5
|
+
import type { AdminHandlerConfig } from './handler.js';
|
|
6
|
+
export declare function scopeFrom(relConfig: {
|
|
7
|
+
where?: (ctx: any) => any;
|
|
8
|
+
} | undefined, ctx: {
|
|
9
|
+
locals?: any;
|
|
10
|
+
}): any;
|
|
11
|
+
export declare function listScopeFrom(runtime: AdminRuntime, model: Model, ctx: {
|
|
12
|
+
locals?: any;
|
|
13
|
+
}): Record<string, unknown> | undefined;
|
|
14
|
+
export declare function modelScopeFrom(runtime: AdminRuntime, model: Model, ctx: {
|
|
15
|
+
locals?: any;
|
|
16
|
+
}): Filter | undefined;
|
|
17
|
+
/** Extract equality predicates so create can force tenant-owned columns. */
|
|
18
|
+
export declare function modelScopeValues(runtime: AdminRuntime, model: Model, ctx: {
|
|
19
|
+
locals?: any;
|
|
20
|
+
}): Record<string, unknown>;
|
|
21
|
+
export interface AdminRuntime {
|
|
22
|
+
adapter: {
|
|
23
|
+
introspector: SchemaIntrospector;
|
|
24
|
+
data: DataAdapter;
|
|
25
|
+
};
|
|
26
|
+
schema: Schema | null;
|
|
27
|
+
relationGraph: RelationGraph | null;
|
|
28
|
+
models: Model[];
|
|
29
|
+
modelList: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
label: string;
|
|
32
|
+
}>;
|
|
33
|
+
config: AdminHandlerConfig;
|
|
34
|
+
basePath: string;
|
|
35
|
+
perPage: number;
|
|
36
|
+
selectThreshold: number;
|
|
37
|
+
filterLinkThreshold: number;
|
|
38
|
+
labelFieldCandidates: string[];
|
|
39
|
+
findModel(name?: string): Model | undefined;
|
|
40
|
+
labelOf(model: Model): string;
|
|
41
|
+
hiddenFieldsOf(model: Model): Set<string>;
|
|
42
|
+
viewModel(model: Model): ViewModel;
|
|
43
|
+
resolveLabel(target: Model, row: Record<string, unknown>, labelTemplate?: string): string;
|
|
44
|
+
resolveFilterableFields(model: Model): Set<string>;
|
|
45
|
+
}
|
|
46
|
+
export declare function createAdminRuntime(config: AdminHandlerConfig): AdminRuntime;
|