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
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ See [CHANGELOG.md](./CHANGELOG.md) for release notes and breaking changes, and
|
|
|
16
16
|
- ⚡ **Zero routes** - everything handled via a single hook
|
|
17
17
|
- 🪶 **3 lines of code** to setup
|
|
18
18
|
- 🔧 **Customizable** - hide fields, set readonly, custom labels
|
|
19
|
+
- 📋 **Audit log** - optional callback after every successful write
|
|
20
|
+
- 🧩 **Plugins** - optional extra pages and record actions (`plugins: []`)
|
|
19
21
|
- 🔌 **Drizzle adapter** (optional subpath export)
|
|
20
22
|
|
|
21
23
|
## Installation
|
|
@@ -46,12 +48,12 @@ That's it! Navigate to `/admin` and you'll see:
|
|
|
46
48
|
|
|
47
49
|
## Drizzle
|
|
48
50
|
|
|
49
|
-
Prisma stays the default
|
|
50
|
-
|
|
51
|
+
Prisma stays the default on the root entry (`createAdminHandler({ prisma })`).
|
|
52
|
+
For Drizzle, import **both** the handler and the adapter from the subpath
|
|
53
|
+
so a Drizzle-only app never evaluates the Prisma adapter modules:
|
|
51
54
|
|
|
52
55
|
```typescript
|
|
53
|
-
import { createAdminHandler } from 'sveltekit-admin';
|
|
54
|
-
import { createDrizzleAdapter } from 'sveltekit-admin/adapters/drizzle';
|
|
56
|
+
import { createAdminHandler, createDrizzleAdapter } from 'sveltekit-admin/adapters/drizzle';
|
|
55
57
|
import { db } from './db';
|
|
56
58
|
import * as schema from './db/schema';
|
|
57
59
|
|
|
@@ -61,6 +63,10 @@ export const handle = createAdminHandler({
|
|
|
61
63
|
});
|
|
62
64
|
```
|
|
63
65
|
|
|
66
|
+
Importing `createAdminHandler` from `sveltekit-admin` and the adapter from
|
|
67
|
+
the subpath still works, but it loads the Prisma adapter JavaScript (it does
|
|
68
|
+
not require installing `@prisma/client`).
|
|
69
|
+
|
|
64
70
|
Pass the same `schema` object you already export (tables + `relations()`).
|
|
65
71
|
Model names in `config.models` are the JS export keys (`users`, not `User`).
|
|
66
72
|
`config.search.mode` only applies to `createAdminHandler({ prisma })`;
|
|
@@ -163,6 +169,37 @@ prefetch would allow), and this route is checked *before* `authCheck`, so
|
|
|
163
169
|
a user whose session already expired can still use it to clean up
|
|
164
170
|
client-side state instead of being stuck behind a 401 with no way back.
|
|
165
171
|
|
|
172
|
+
### Audit log
|
|
173
|
+
|
|
174
|
+
Same philosophy as `authCheck` / `logout`: the library has no log table of
|
|
175
|
+
its own. Provide an `audit` callback and it is called after every
|
|
176
|
+
**successful** create, update, or delete with a redacted `AuditEvent`.
|
|
177
|
+
The actor is whatever you already put on `event.locals`. Sensitive and
|
|
178
|
+
`hidden` fields are stripped. If the callback throws, the mutation still
|
|
179
|
+
redirects.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
const adminHandle = createAdminHandler({
|
|
183
|
+
prisma,
|
|
184
|
+
authCheck: (event) => event.locals.session?.user?.role === 'admin',
|
|
185
|
+
audit: async (entry) => {
|
|
186
|
+
await prisma.auditLog.create({
|
|
187
|
+
data: {
|
|
188
|
+
at: entry.at,
|
|
189
|
+
actorId: entry.event.locals.session?.user?.id,
|
|
190
|
+
action: entry.action,
|
|
191
|
+
model: entry.model,
|
|
192
|
+
recordId: String(entry.id),
|
|
193
|
+
changes: entry.action === 'update' ? entry.changes : undefined
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
No callback means no behaviour change. Persist to your own model if you
|
|
201
|
+
want the log to appear in the admin like any other table.
|
|
202
|
+
|
|
166
203
|
## How It Works
|
|
167
204
|
|
|
168
205
|
The admin handler intercepts all requests to `/admin/*` and:
|
|
@@ -177,6 +214,26 @@ Routes handled:
|
|
|
177
214
|
- `/admin/user/new` → Create user form
|
|
178
215
|
- `/admin/user/123` → Edit user form
|
|
179
216
|
|
|
217
|
+
## Plugins
|
|
218
|
+
|
|
219
|
+
Pass `plugins` to register extra admin pages (SSR HTML + inline CSS/JS)
|
|
220
|
+
and links on edit screens and list rows. See the exported `AdminPlugin`
|
|
221
|
+
type and the documentation site's Plugins page.
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
createAdminHandler({
|
|
225
|
+
prisma,
|
|
226
|
+
plugins: [
|
|
227
|
+
{
|
|
228
|
+
name: 'hello',
|
|
229
|
+
pages: [{ pattern: ['hello'], render: () => ({ html: '<p>Hello</p>' }) }]
|
|
230
|
+
}
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Omit `plugins` and the admin is unchanged.
|
|
236
|
+
|
|
180
237
|
## Model Configuration
|
|
181
238
|
|
|
182
239
|
```typescript
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* SvelteKit Admin
|
|
3
3
|
* Django-like admin panel for SvelteKit + Prisma
|
|
4
4
|
*/
|
|
5
|
-
export { createAdminHandler, type AdminHandlerConfig } from './server/handler.js';
|
|
5
|
+
export { createAdminHandler, type AdminHandlerConfig } from './server/adapters/prisma/handler.js';
|
|
6
6
|
export { defaultAdminCheck } from './server/auth.js';
|
|
7
|
+
export type { AuditAction, AuditEvent } from './server/audit.js';
|
|
7
8
|
export { parsePrismaSchema, parseSchemaContent, type PrismaSchema, type PrismaModel, type PrismaField } from './server/introspection/parser.js';
|
|
8
9
|
export type { Schema, Model, Field } from './server/types/schema.js';
|
|
9
10
|
export { createPrismaAdapter } from './server/adapters/prisma/index.js';
|
|
10
11
|
export type { DataAdapter, SchemaIntrospector, Filter } from './server/adapters/types.js';
|
|
12
|
+
export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult } from './server/plugin.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* SvelteKit Admin
|
|
3
3
|
* Django-like admin panel for SvelteKit + Prisma
|
|
4
4
|
*/
|
|
5
|
-
export { createAdminHandler } from './server/handler.js';
|
|
5
|
+
export { createAdminHandler } from './server/adapters/prisma/handler.js';
|
|
6
6
|
export { defaultAdminCheck } from './server/auth.js';
|
|
7
7
|
export { parsePrismaSchema, parseSchemaContent } from './server/introspection/parser.js';
|
|
8
8
|
export { createPrismaAdapter } from './server/adapters/prisma/index.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { asc, count, desc, eq, getTableColumns } from "drizzle-orm";
|
|
1
|
+
import { and, asc, count, desc, eq, getTableColumns, inArray } from "drizzle-orm";
|
|
2
2
|
import { coerceId, primaryKeyOf } from "../../data.js";
|
|
3
|
+
import { withWriteRetry } from "../retry.js";
|
|
3
4
|
import { compileFilterToDrizzle } from "./filterCompiler.js";
|
|
4
5
|
function tableFor(ctx, model) {
|
|
5
6
|
const table = ctx.tables[model.name];
|
|
@@ -11,11 +12,73 @@ function tableFor(ctx, model) {
|
|
|
11
12
|
function primaryKeyColumn(table, model) {
|
|
12
13
|
return getTableColumns(table)[primaryKeyOf(model)];
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Résolution d'une colonne par nom, partagée par les deux chemins de tri
|
|
17
|
+
* (`listRecords` pour `?sort=`, `findMany` pour un `orderBy` de config). Un
|
|
18
|
+
* nom inconnu lève : côté `?sort=` il ne peut pas arriver — `sortQuery.ts` ne
|
|
19
|
+
* laisse sortir que des colonnes rendues — donc s'il arrive, c'est la table
|
|
20
|
+
* Drizzle qui ne correspond pas au schéma introspecté, et échouer fort vaut
|
|
21
|
+
* mieux qu'un tri silencieusement absent.
|
|
22
|
+
*/
|
|
23
|
+
function columnFor(table, field) {
|
|
24
|
+
const column = getTableColumns(table)[field];
|
|
25
|
+
if (!column) {
|
|
26
|
+
throw new Error(`[sveltekit-admin] unknown field '${field}' on Drizzle table`);
|
|
27
|
+
}
|
|
28
|
+
return column;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Ordre de verrouillage déterministe. Les guards m2m sont empilés dans l'ordre
|
|
32
|
+
* des ids soumis par le formulaire, donc sous contrôle du client : deux
|
|
33
|
+
* requêtes concurrentes envoyant [1,2] et [2,1] verrouilleraient les mêmes
|
|
34
|
+
* lignes en sens inverse et se deadlockeraient (PostgreSQL 40P01), ce qui est
|
|
35
|
+
* bien plus facile à déclencher que la course qu'on cherche à empêcher.
|
|
36
|
+
* Trier sur (modèle, pk) donne un ordre total stable et supprime le cycle.
|
|
37
|
+
*/
|
|
38
|
+
function orderedGuards(guards) {
|
|
39
|
+
return [...guards].sort((a, b) => a.targetModel.name.localeCompare(b.targetModel.name) ||
|
|
40
|
+
String(a.targetPk).localeCompare(String(b.targetPk)));
|
|
41
|
+
}
|
|
42
|
+
async function validateTargetGuards(ctx, tx, guards, compile) {
|
|
43
|
+
for (const guard of orderedGuards(guards)) {
|
|
44
|
+
const table = tableFor(ctx, guard.targetModel);
|
|
45
|
+
const where = and(eq(primaryKeyColumn(table, guard.targetModel), guard.targetPk), compile(table, guard.filter));
|
|
46
|
+
const query = tx.select().from(table).where(where).limit(1);
|
|
47
|
+
// Verrou partagé tenu jusqu'au commit, PostgreSQL uniquement.
|
|
48
|
+
//
|
|
49
|
+
// Mesuré (PG 16, les 4 combinaisons) : SERIALIZABLE seul n'annule PAS la
|
|
50
|
+
// séquence « lire le guard -> un tiers sort la cible du scope -> écrire ».
|
|
51
|
+
// SSI n'y voit aucun cycle de dépendances, donc cet ordre reste
|
|
52
|
+
// sérialisable et les deux transactions committent. Seul le verrou de
|
|
53
|
+
// ligne ferme la fenêtre. Ne pas le retirer en pensant que le niveau
|
|
54
|
+
// d'isolation suffit : c'est faux, et ça a été vérifié.
|
|
55
|
+
//
|
|
56
|
+
// MySQL est exclu volontairement : SERIALIZABLE y transforme déjà les
|
|
57
|
+
// SELECT en lectures verrouillantes (mesuré : le writer concurrent est
|
|
58
|
+
// bloqué), donc le verrou n'apporterait rien — et `for share` est une
|
|
59
|
+
// syntaxe 8.0+, l'émettre casserait les schémas encore en 5.7.
|
|
60
|
+
const rows = await (ctx.dialect === "postgresql" ? query.for("share") : query);
|
|
61
|
+
if (rows.length === 0)
|
|
62
|
+
throw new Error("relation target is outside the authorization scope");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function validateTargetGuardsSqlite(ctx, tx, guards, compile) {
|
|
66
|
+
// Pas de clause de verrou en SQLite (absente de sqlite-core, et inutile :
|
|
67
|
+
// l'écriture concurrente échoue fermée, cf. le commentaire ci-dessus).
|
|
68
|
+
for (const guard of orderedGuards(guards)) {
|
|
69
|
+
const table = tableFor(ctx, guard.targetModel);
|
|
70
|
+
const where = and(eq(primaryKeyColumn(table, guard.targetModel), guard.targetPk), compile(table, guard.filter));
|
|
71
|
+
const row = tx.select().from(table).where(where).limit(1).get();
|
|
72
|
+
if (!row)
|
|
73
|
+
throw new Error("relation target is outside the authorization scope");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function selectByPrimaryKey(db, table, model, id, authorizationFilter) {
|
|
77
|
+
const primaryKeyWhere = eq(primaryKeyColumn(table, model), coerceId(String(id), model));
|
|
15
78
|
const rows = await db
|
|
16
79
|
.select()
|
|
17
80
|
.from(table)
|
|
18
|
-
.where(
|
|
81
|
+
.where(authorizationFilter ? and(primaryKeyWhere, authorizationFilter) : primaryKeyWhere)
|
|
19
82
|
.limit(1);
|
|
20
83
|
return rows[0] ?? null;
|
|
21
84
|
}
|
|
@@ -32,15 +95,23 @@ async function insertAndReturn(db, table, model, scalars, dialect) {
|
|
|
32
95
|
const rows = await db.insert(table).values(scalars).returning();
|
|
33
96
|
return rows[0];
|
|
34
97
|
}
|
|
35
|
-
async function updateAndReturn(db, table, model, id, scalars, dialect) {
|
|
98
|
+
async function updateAndReturn(db, table, model, id, scalars, dialect, authorizationFilter) {
|
|
36
99
|
const coercedId = coerceId(String(id), model);
|
|
37
|
-
const where =
|
|
100
|
+
const where = authorizationFilter
|
|
101
|
+
? and(eq(primaryKeyColumn(table, model), coercedId), authorizationFilter)
|
|
102
|
+
: eq(primaryKeyColumn(table, model), coercedId);
|
|
38
103
|
if (dialect === "mysql") {
|
|
39
104
|
await db.update(table).set(scalars).where(where);
|
|
40
|
-
|
|
105
|
+
const row = await selectByPrimaryKey(db, table, model, coercedId, authorizationFilter);
|
|
106
|
+
if (!row)
|
|
107
|
+
throw new Error("record is outside the authorization scope");
|
|
108
|
+
return row;
|
|
41
109
|
}
|
|
42
110
|
const rows = await db.update(table).set(scalars).where(where).returning();
|
|
43
|
-
|
|
111
|
+
const row = rows[0];
|
|
112
|
+
if (!row)
|
|
113
|
+
throw new Error("record is outside the authorization scope");
|
|
114
|
+
return row;
|
|
44
115
|
}
|
|
45
116
|
async function insertM2mRows(db, link, parentId, ids) {
|
|
46
117
|
if (ids.length === 0)
|
|
@@ -53,13 +124,16 @@ async function insertM2mRows(db, link, parentId, ids) {
|
|
|
53
124
|
function insertAndReturnSqlite(db, table, scalars) {
|
|
54
125
|
return db.insert(table).values(scalars).returning().get();
|
|
55
126
|
}
|
|
56
|
-
function updateAndReturnSqlite(db, table, model, id, scalars) {
|
|
57
|
-
|
|
127
|
+
function updateAndReturnSqlite(db, table, model, id, scalars, authorizationFilter) {
|
|
128
|
+
const row = db
|
|
58
129
|
.update(table)
|
|
59
130
|
.set(scalars)
|
|
60
|
-
.where(eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
|
|
131
|
+
.where(authorizationFilter ? and(eq(primaryKeyColumn(table, model), coerceId(String(id), model)), authorizationFilter) : eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
|
|
61
132
|
.returning()
|
|
62
133
|
.get();
|
|
134
|
+
if (!row)
|
|
135
|
+
throw new Error("record is outside the authorization scope");
|
|
136
|
+
return row;
|
|
63
137
|
}
|
|
64
138
|
function insertM2mRowsSqlite(db, link, parentId, ids) {
|
|
65
139
|
if (ids.length === 0)
|
|
@@ -81,12 +155,23 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
81
155
|
const table = tableFor(ctx, model);
|
|
82
156
|
const where = compileHere(table, opts.filter);
|
|
83
157
|
const primaryKey = primaryKeyColumn(table, model);
|
|
158
|
+
const requested = opts.orderBy;
|
|
159
|
+
// Départage systématique par la clé primaire, sauf quand c'est elle qu'on
|
|
160
|
+
// trie : deux lignes de même valeur doivent garder le même rang d'une
|
|
161
|
+
// requête à l'autre, sinon la fenêtre skip/take fait sauter des lignes.
|
|
162
|
+
const orderBy = !requested
|
|
163
|
+
? [desc(primaryKey)]
|
|
164
|
+
: (() => {
|
|
165
|
+
const column = columnFor(table, requested.field);
|
|
166
|
+
const primary = column === primaryKey ? [] : [desc(primaryKey)];
|
|
167
|
+
return [requested.dir === "asc" ? asc(column) : desc(column), ...primary];
|
|
168
|
+
})();
|
|
84
169
|
const [rows, totals] = await Promise.all([
|
|
85
170
|
db
|
|
86
171
|
.select()
|
|
87
172
|
.from(table)
|
|
88
173
|
.where(where)
|
|
89
|
-
.orderBy(
|
|
174
|
+
.orderBy(...orderBy)
|
|
90
175
|
.limit(opts.take)
|
|
91
176
|
.offset(opts.skip),
|
|
92
177
|
db.select({ n: count() }).from(table).where(where),
|
|
@@ -95,12 +180,8 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
95
180
|
},
|
|
96
181
|
async findMany(model, opts) {
|
|
97
182
|
const table = tableFor(ctx, model);
|
|
98
|
-
const columns = getTableColumns(table);
|
|
99
183
|
const orderBy = Object.entries(opts.orderBy ?? {}).map(([field, direction]) => {
|
|
100
|
-
const column =
|
|
101
|
-
if (!column) {
|
|
102
|
-
throw new Error(`[sveltekit-admin] unknown field '${field}' on Drizzle table`);
|
|
103
|
-
}
|
|
184
|
+
const column = columnFor(table, field);
|
|
104
185
|
return direction === "asc" ? asc(column) : desc(column);
|
|
105
186
|
});
|
|
106
187
|
let query = db
|
|
@@ -138,11 +219,13 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
138
219
|
async createRecord(model, input) {
|
|
139
220
|
const table = tableFor(ctx, model);
|
|
140
221
|
const m2mFields = Object.entries(input.m2m ?? {});
|
|
141
|
-
|
|
222
|
+
const guards = input.targetGuards ?? [];
|
|
223
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
142
224
|
return insertAndReturn(db, table, model, input.scalars, ctx.dialect);
|
|
143
225
|
}
|
|
144
226
|
if (ctx.dialect === "sqlite") {
|
|
145
227
|
return db.transaction((tx) => {
|
|
228
|
+
validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
|
|
146
229
|
const parent = insertAndReturnSqlite(tx, table, input.scalars);
|
|
147
230
|
const parentId = parent[primaryKeyOf(model)];
|
|
148
231
|
for (const [field, relation] of m2mFields) {
|
|
@@ -152,9 +235,10 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
152
235
|
insertM2mRowsSqlite(tx, link, parentId, relation.ids);
|
|
153
236
|
}
|
|
154
237
|
return parent;
|
|
155
|
-
});
|
|
238
|
+
}, { behavior: "immediate" });
|
|
156
239
|
}
|
|
157
|
-
return db.transaction(async (tx) => {
|
|
240
|
+
return withWriteRetry(() => db.transaction(async (tx) => {
|
|
241
|
+
await validateTargetGuards(ctx, tx, guards, compileHere);
|
|
158
242
|
const parent = await insertAndReturn(tx, table, model, input.scalars, ctx.dialect);
|
|
159
243
|
const parentId = parent[primaryKeyOf(model)];
|
|
160
244
|
for (const [field, relation] of m2mFields) {
|
|
@@ -164,17 +248,19 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
164
248
|
await insertM2mRows(tx, link, parentId, relation.ids);
|
|
165
249
|
}
|
|
166
250
|
return parent;
|
|
167
|
-
});
|
|
251
|
+
}, { isolationLevel: "serializable" }));
|
|
168
252
|
},
|
|
169
|
-
async updateRecord(model, id, input) {
|
|
253
|
+
async updateRecord(model, id, input, authorizationFilter) {
|
|
170
254
|
const table = tableFor(ctx, model);
|
|
171
255
|
const m2mFields = Object.entries(input.m2m ?? {});
|
|
172
|
-
|
|
173
|
-
|
|
256
|
+
const guards = input.targetGuards ?? [];
|
|
257
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
258
|
+
return updateAndReturn(db, table, model, id, input.scalars, ctx.dialect, compileHere(table, authorizationFilter));
|
|
174
259
|
}
|
|
175
260
|
if (ctx.dialect === "sqlite") {
|
|
176
261
|
return db.transaction((tx) => {
|
|
177
|
-
|
|
262
|
+
validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
|
|
263
|
+
const parent = updateAndReturnSqlite(tx, table, model, id, input.scalars, compileHere(table, authorizationFilter));
|
|
178
264
|
const parentId = coerceId(String(id), model);
|
|
179
265
|
for (const [field, relation] of m2mFields) {
|
|
180
266
|
const link = ctx.m2m.get(`${model.name}.${field}`);
|
|
@@ -184,10 +270,11 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
184
270
|
insertM2mRowsSqlite(tx, link, parentId, relation.ids);
|
|
185
271
|
}
|
|
186
272
|
return parent;
|
|
187
|
-
});
|
|
273
|
+
}, { behavior: "immediate" });
|
|
188
274
|
}
|
|
189
|
-
return db.transaction(async (tx) => {
|
|
190
|
-
|
|
275
|
+
return withWriteRetry(() => db.transaction(async (tx) => {
|
|
276
|
+
await validateTargetGuards(ctx, tx, guards, compileHere);
|
|
277
|
+
const parent = await updateAndReturn(tx, table, model, id, input.scalars, ctx.dialect, compileHere(table, authorizationFilter));
|
|
191
278
|
const parentId = coerceId(String(id), model);
|
|
192
279
|
for (const [field, relation] of m2mFields) {
|
|
193
280
|
const link = ctx.m2m.get(`${model.name}.${field}`);
|
|
@@ -197,37 +284,104 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
197
284
|
await insertM2mRows(tx, link, parentId, relation.ids);
|
|
198
285
|
}
|
|
199
286
|
return parent;
|
|
200
|
-
});
|
|
287
|
+
}, { isolationLevel: "serializable" }));
|
|
201
288
|
},
|
|
202
|
-
async deleteRecord(model, id) {
|
|
289
|
+
async deleteRecord(model, id, authorizationFilter) {
|
|
203
290
|
const table = tableFor(ctx, model);
|
|
204
291
|
const coercedId = coerceId(String(id), model);
|
|
205
292
|
const links = [...ctx.m2m.entries()]
|
|
206
293
|
.filter(([key]) => key.startsWith(`${model.name}.`))
|
|
207
294
|
.map(([, link]) => link);
|
|
295
|
+
const parentWhere = and(eq(primaryKeyColumn(table, model), coercedId), compileHere(table, authorizationFilter));
|
|
208
296
|
if (links.length === 0) {
|
|
209
|
-
|
|
210
|
-
.delete(table)
|
|
211
|
-
.
|
|
297
|
+
if (ctx.dialect === "sqlite") {
|
|
298
|
+
const result = db.delete(table).where(parentWhere).run();
|
|
299
|
+
if (result.changes !== 1)
|
|
300
|
+
throw new Error("record is outside the authorization scope");
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const result = await db.delete(table).where(parentWhere);
|
|
304
|
+
if (Number(result?.affectedRows ?? 0) !== 1)
|
|
305
|
+
throw new Error("record is outside the authorization scope");
|
|
306
|
+
}
|
|
212
307
|
return;
|
|
213
308
|
}
|
|
309
|
+
// Les pivots partent avant le parent, l'ordre qu'imposent les FK. Le DELETE
|
|
310
|
+
// scopé du parent sert lui-même de garde : zéro ligne touchée => throw =>
|
|
311
|
+
// rollback des pivots. Pas de SELECT de vérification préalable, donc aucune
|
|
312
|
+
// fenêtre TOCTOU entre la lecture du scope et la suppression, et aucune
|
|
313
|
+
// branche défensive inatteignable.
|
|
214
314
|
if (ctx.dialect === "sqlite") {
|
|
215
315
|
db.transaction((tx) => {
|
|
216
|
-
for (const link of links)
|
|
316
|
+
for (const link of links)
|
|
217
317
|
tx.delete(link.pivot).where(eq(link.selfColumn, coercedId)).run();
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
});
|
|
318
|
+
const result = tx.delete(table).where(parentWhere).run();
|
|
319
|
+
if (result.changes !== 1)
|
|
320
|
+
throw new Error("record is outside the authorization scope");
|
|
321
|
+
}, { behavior: "immediate" });
|
|
223
322
|
return;
|
|
224
323
|
}
|
|
225
|
-
await db.transaction(async (tx) => {
|
|
226
|
-
for (const link of links)
|
|
324
|
+
await withWriteRetry(() => db.transaction(async (tx) => {
|
|
325
|
+
for (const link of links)
|
|
227
326
|
await tx.delete(link.pivot).where(eq(link.selfColumn, coercedId));
|
|
327
|
+
if (ctx.dialect === "postgresql") {
|
|
328
|
+
const deleted = await tx.delete(table).where(parentWhere).returning({ id: primaryKeyColumn(table, model) });
|
|
329
|
+
if (deleted.length !== 1)
|
|
330
|
+
throw new Error("record is outside the authorization scope");
|
|
228
331
|
}
|
|
229
|
-
|
|
230
|
-
|
|
332
|
+
else {
|
|
333
|
+
const result = await tx.delete(table).where(parentWhere);
|
|
334
|
+
if (Number(result?.affectedRows ?? 0) !== 1)
|
|
335
|
+
throw new Error("record is outside the authorization scope");
|
|
336
|
+
}
|
|
337
|
+
}, { isolationLevel: "serializable" }));
|
|
338
|
+
},
|
|
339
|
+
async deleteMany(model, ids, authorizationFilter) {
|
|
340
|
+
const table = tableFor(ctx, model);
|
|
341
|
+
const primaryKey = primaryKeyColumn(table, model);
|
|
342
|
+
const coerced = ids.map((id) => coerceId(String(id), model));
|
|
343
|
+
const scopedWhere = and(inArray(primaryKey, coerced), compileHere(table, authorizationFilter));
|
|
344
|
+
const links = [...ctx.m2m.entries()]
|
|
345
|
+
.filter(([key]) => key.startsWith(`${model.name}.`))
|
|
346
|
+
.map(([, link]) => link);
|
|
347
|
+
/**
|
|
348
|
+
* Les ids réellement concernés sont LUS avant de toucher quoi que ce
|
|
349
|
+
* soit, à l'intérieur de la transaction. Ce n'est pas une vérification
|
|
350
|
+
* défensive : les pivots m2m doivent être supprimés pour ces lignes-là et
|
|
351
|
+
* pas pour les autres. Composer le scope directement dans le DELETE des
|
|
352
|
+
* pivots effacerait les liaisons d'une ligne hors portée que le DELETE du
|
|
353
|
+
* parent, lui, ne toucherait pas — une ligne d'un autre tenant amputée de
|
|
354
|
+
* ses relations sans que rien ne l'indique.
|
|
355
|
+
*
|
|
356
|
+
* `deleteRecord` peut s'en passer parce qu'il vise UNE ligne et lève quand
|
|
357
|
+
* elle n'est pas dans la portée, ce qui annule tout. Ici une portée
|
|
358
|
+
* partielle est un résultat normal, pas une erreur : le compte renvoyé est
|
|
359
|
+
* celui des lignes supprimées.
|
|
360
|
+
*/
|
|
361
|
+
if (ctx.dialect === "sqlite") {
|
|
362
|
+
return db.transaction((tx) => {
|
|
363
|
+
const matched = tx.select({ id: primaryKey }).from(table).where(scopedWhere).all()
|
|
364
|
+
.map((row) => row.id);
|
|
365
|
+
if (matched.length === 0)
|
|
366
|
+
return 0;
|
|
367
|
+
for (const link of links) {
|
|
368
|
+
tx.delete(link.pivot).where(inArray(link.selfColumn, matched)).run();
|
|
369
|
+
}
|
|
370
|
+
tx.delete(table).where(inArray(primaryKey, matched)).run();
|
|
371
|
+
return matched.length;
|
|
372
|
+
}, { behavior: "immediate" });
|
|
373
|
+
}
|
|
374
|
+
return withWriteRetry(() => db.transaction(async (tx) => {
|
|
375
|
+
const rows = await tx.select({ id: primaryKey }).from(table).where(scopedWhere);
|
|
376
|
+
const matched = rows.map((row) => row.id);
|
|
377
|
+
if (matched.length === 0)
|
|
378
|
+
return 0;
|
|
379
|
+
for (const link of links) {
|
|
380
|
+
await tx.delete(link.pivot).where(inArray(link.selfColumn, matched));
|
|
381
|
+
}
|
|
382
|
+
await tx.delete(table).where(inArray(primaryKey, matched));
|
|
383
|
+
return matched.length;
|
|
384
|
+
}, { isolationLevel: "serializable" }));
|
|
231
385
|
},
|
|
232
386
|
async getM2mSelectedIds(model, edge, _targetModel, recordId) {
|
|
233
387
|
const link = ctx.m2m.get(`${model.name}.${edge.field}`);
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { defaultAdminCheck } from "../../auth.js";
|
|
2
|
+
import { createAdminHandler, type AdminHandlerConfig } from "../../handler.js";
|
|
3
|
+
import type { Schema, Model, Field } from "../../types/schema.js";
|
|
4
|
+
import type { DataAdapter, Filter, SchemaIntrospector } from "../types.js";
|
|
2
5
|
import type { DrizzleDialect } from "./inspect.js";
|
|
6
|
+
export { createAdminHandler, type AdminHandlerConfig };
|
|
7
|
+
export { defaultAdminCheck };
|
|
8
|
+
export type { AuditAction, AuditEvent } from "../../audit.js";
|
|
9
|
+
export type { Schema, Model, Field };
|
|
10
|
+
export type { DataAdapter, SchemaIntrospector, Filter };
|
|
3
11
|
export type { DrizzleDialect };
|
|
12
|
+
export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult, } from "../../plugin.js";
|
|
4
13
|
export declare function resolveCaseInsensitiveSearch(dialect: DrizzleDialect, searchMode?: "auto" | "insensitive" | "default"): boolean;
|
|
5
14
|
export declare function createDrizzleAdapter(opts: {
|
|
6
15
|
db: any;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { defaultAdminCheck } from "../../auth.js";
|
|
2
|
+
import { createAdminHandler } from "../../handler.js";
|
|
1
3
|
import { createDrizzleDataAdapter } from "./dataAdapter.js";
|
|
2
4
|
import { inspectDrizzleSchema } from "./inspect.js";
|
|
3
5
|
import { createDrizzleIntrospector } from "./introspector.js";
|
|
6
|
+
export { createAdminHandler };
|
|
7
|
+
export { defaultAdminCheck };
|
|
4
8
|
export function resolveCaseInsensitiveSearch(dialect, searchMode = "auto") {
|
|
5
9
|
if (searchMode === "insensitive")
|
|
6
10
|
return true;
|
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
import { toPrismaModel, primaryKeyOf, coerceId } from '../../data.js';
|
|
2
2
|
import { compileFilterToPrismaWhere } from './filterCompiler.js';
|
|
3
|
+
import { withWriteRetry } from '../retry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Revalidation des cibles de relation à l'intérieur de la transaction d'écriture.
|
|
6
|
+
*
|
|
7
|
+
* Fenêtre résiduelle, assumée : cette lecture ne pose aucun verrou de ligne.
|
|
8
|
+
* Sur PostgreSQL, `Serializable` ne l'empêche PAS — mesuré sur PG 16 : SSI ne
|
|
9
|
+
* voit aucun cycle de dépendances dans « lire le guard -> un tiers sort la
|
|
10
|
+
* cible du scope -> écrire », donc les deux transactions committent. L'adapter
|
|
11
|
+
* Drizzle ferme cette fenêtre avec un `FOR SHARE` ; Prisma n'expose aucune API
|
|
12
|
+
* de verrou, et l'émettre demanderait du `$queryRaw` par dialecte, donc les
|
|
13
|
+
* noms physiques de tables et colonnes (les `@@map`/`@map` ne sont pas parsés)
|
|
14
|
+
* et un second compilateur de filtres à garder en phase avec
|
|
15
|
+
* `compileFilterToPrismaWhere` — exactement le duplicata divergent que ce
|
|
16
|
+
* codebase a déjà payé une fois.
|
|
17
|
+
*
|
|
18
|
+
* L'exposition reste bornée : `mutations.ts` a déjà revalidé chaque FK et m2m
|
|
19
|
+
* par un `findFirst` scopé avant d'appeler l'adapter, et gagner cette course
|
|
20
|
+
* ne produit qu'une référence orpheline inter-tenant — aucune lecture des
|
|
21
|
+
* données de l'autre tenant, le scoping des dropdowns et la « chip » anti-oracle
|
|
22
|
+
* tenant par ailleurs. C'est un défaut d'intégrité, pas une divulgation.
|
|
23
|
+
*/
|
|
24
|
+
async function validateTargetGuards(tx, guards, compile) {
|
|
25
|
+
for (const guard of guards) {
|
|
26
|
+
const key = toPrismaModel(guard.targetModel.name);
|
|
27
|
+
const pk = primaryKeyOf(guard.targetModel);
|
|
28
|
+
const where = guard.filter
|
|
29
|
+
? { [pk]: guard.targetPk, AND: [compile(guard.filter)] }
|
|
30
|
+
: { [pk]: guard.targetPk };
|
|
31
|
+
if (!(await tx[key].findFirst({ where }))) {
|
|
32
|
+
throw new Error('relation target is outside the authorization scope');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
3
36
|
/**
|
|
4
37
|
* Prisma implementation of `DataAdapter`. `caseInsensitiveSearch` is fixed
|
|
5
38
|
* at construction time (Task 5's boot block resolves it from the schema's
|
|
@@ -16,8 +49,17 @@ export function createPrismaDataAdapter(prisma, opts = {}) {
|
|
|
16
49
|
const key = toPrismaModel(model.name);
|
|
17
50
|
const primaryKey = primaryKeyOf(model);
|
|
18
51
|
const where = compileHere(listOpts.filter);
|
|
52
|
+
const requested = listOpts.orderBy;
|
|
53
|
+
// Objet (et non tableau à un élément) quand rien n'est demandé : c'est la
|
|
54
|
+
// forme historique, et la garder évite de faire bouger des attentes de
|
|
55
|
+
// tests qui ne parlent pas de tri.
|
|
56
|
+
const orderBy = !requested
|
|
57
|
+
? { [primaryKey]: 'desc' }
|
|
58
|
+
: requested.field === primaryKey
|
|
59
|
+
? [{ [primaryKey]: requested.dir }]
|
|
60
|
+
: [{ [requested.field]: requested.dir }, { [primaryKey]: 'desc' }];
|
|
19
61
|
const [rows, total] = await Promise.all([
|
|
20
|
-
prisma[key].findMany({ where, skip: listOpts.skip, take: listOpts.take, orderBy
|
|
62
|
+
prisma[key].findMany({ where, skip: listOpts.skip, take: listOpts.take, orderBy }),
|
|
21
63
|
prisma[key].count({ where })
|
|
22
64
|
]);
|
|
23
65
|
return { rows, total };
|
|
@@ -44,38 +86,56 @@ export function createPrismaDataAdapter(prisma, opts = {}) {
|
|
|
44
86
|
async createRecord(model, input) {
|
|
45
87
|
const key = toPrismaModel(model.name);
|
|
46
88
|
const m2mFields = Object.keys(input.m2m ?? {});
|
|
47
|
-
|
|
89
|
+
const guards = input.targetGuards ?? [];
|
|
90
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
48
91
|
return prisma[key].create({ data: input.scalars });
|
|
49
92
|
}
|
|
50
|
-
return prisma.$transaction(async (tx) => {
|
|
93
|
+
return withWriteRetry(() => prisma.$transaction(async (tx) => {
|
|
94
|
+
await validateTargetGuards(tx, guards, compileHere);
|
|
51
95
|
const data = { ...input.scalars };
|
|
52
96
|
for (const field of m2mFields) {
|
|
53
97
|
const { targetPkField, ids } = input.m2m[field];
|
|
54
98
|
data[field] = { connect: ids.map((id) => ({ [targetPkField]: id })) };
|
|
55
99
|
}
|
|
56
100
|
return tx[key].create({ data });
|
|
57
|
-
});
|
|
101
|
+
}, { isolationLevel: "Serializable" }));
|
|
58
102
|
},
|
|
59
|
-
async updateRecord(model, id, input) {
|
|
103
|
+
async updateRecord(model, id, input, authorizationFilter) {
|
|
60
104
|
const key = toPrismaModel(model.name);
|
|
61
105
|
const primaryKey = primaryKeyOf(model);
|
|
62
|
-
const where =
|
|
106
|
+
const where = authorizationFilter
|
|
107
|
+
? { [primaryKey]: coerceId(String(id), model), AND: [compileHere(authorizationFilter)] }
|
|
108
|
+
: { [primaryKey]: coerceId(String(id), model) };
|
|
63
109
|
const m2mFields = Object.keys(input.m2m ?? {});
|
|
64
|
-
|
|
110
|
+
const guards = input.targetGuards ?? [];
|
|
111
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
65
112
|
return prisma[key].update({ where, data: input.scalars });
|
|
66
113
|
}
|
|
67
|
-
return prisma.$transaction(async (tx) => {
|
|
114
|
+
return withWriteRetry(() => prisma.$transaction(async (tx) => {
|
|
115
|
+
await validateTargetGuards(tx, guards, compileHere);
|
|
68
116
|
const data = { ...input.scalars };
|
|
69
117
|
for (const field of m2mFields) {
|
|
70
118
|
const { targetPkField, ids } = input.m2m[field];
|
|
71
119
|
data[field] = { set: ids.map((id) => ({ [targetPkField]: id })) };
|
|
72
120
|
}
|
|
73
121
|
return tx[key].update({ where, data });
|
|
74
|
-
});
|
|
122
|
+
}, { isolationLevel: "Serializable" }));
|
|
123
|
+
},
|
|
124
|
+
async deleteRecord(model, id, authorizationFilter) {
|
|
125
|
+
const primaryKey = primaryKeyOf(model);
|
|
126
|
+
const where = authorizationFilter
|
|
127
|
+
? { [primaryKey]: coerceId(String(id), model), AND: [compileHere(authorizationFilter)] }
|
|
128
|
+
: { [primaryKey]: coerceId(String(id), model) };
|
|
129
|
+
await prisma[toPrismaModel(model.name)].delete({ where });
|
|
75
130
|
},
|
|
76
|
-
async
|
|
131
|
+
async deleteMany(model, ids, authorizationFilter) {
|
|
77
132
|
const primaryKey = primaryKeyOf(model);
|
|
78
|
-
|
|
133
|
+
const coerced = ids.map((id) => coerceId(String(id), model));
|
|
134
|
+
const where = authorizationFilter
|
|
135
|
+
? { [primaryKey]: { in: coerced }, AND: [compileHere(authorizationFilter)] }
|
|
136
|
+
: { [primaryKey]: { in: coerced } };
|
|
137
|
+
const { count } = await prisma[toPrismaModel(model.name)].deleteMany({ where });
|
|
138
|
+
return count;
|
|
79
139
|
},
|
|
80
140
|
async getM2mSelectedIds(model, edge, targetModel, recordId) {
|
|
81
141
|
try {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AdminHandlerConfig as CoreAdminHandlerConfig } from '../../handler.js';
|
|
2
|
+
export interface AdminHandlerConfig extends Omit<CoreAdminHandlerConfig, 'adapter' | 'prisma' | 'prismaSchemaPath' | 'search'> {
|
|
3
|
+
prisma?: any;
|
|
4
|
+
prismaSchemaPath?: string;
|
|
5
|
+
adapter?: CoreAdminHandlerConfig['adapter'];
|
|
6
|
+
search?: {
|
|
7
|
+
mode?: 'auto' | 'insensitive' | 'default';
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function createAdminHandler(config: AdminHandlerConfig): ({ event, resolve }: {
|
|
11
|
+
event: any;
|
|
12
|
+
resolve: (event: any) => Response | Promise<Response>;
|
|
13
|
+
}) => Promise<Response>;
|
|
14
|
+
export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult } from '../../plugin.js';
|