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.
Files changed (69) hide show
  1. package/README.md +61 -4
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +1 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
  5. package/dist/server/adapters/drizzle/index.d.ts +10 -1
  6. package/dist/server/adapters/drizzle/index.js +4 -0
  7. package/dist/server/adapters/prisma/dataAdapter.js +71 -11
  8. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  9. package/dist/server/adapters/prisma/handler.js +37 -0
  10. package/dist/server/adapters/retry.d.ts +27 -0
  11. package/dist/server/adapters/retry.js +53 -0
  12. package/dist/server/adapters/types.d.ts +42 -3
  13. package/dist/server/audit.d.ts +65 -0
  14. package/dist/server/audit.js +106 -0
  15. package/dist/server/csrf.d.ts +33 -0
  16. package/dist/server/csrf.js +55 -0
  17. package/dist/server/data.d.ts +18 -2
  18. package/dist/server/data.js +39 -8
  19. package/dist/server/errors.d.ts +47 -0
  20. package/dist/server/errors.js +90 -0
  21. package/dist/server/handler.d.ts +92 -26
  22. package/dist/server/handler.js +263 -560
  23. package/dist/server/introspection/parser.d.ts +15 -0
  24. package/dist/server/introspection/parser.js +17 -0
  25. package/dist/server/mutations.d.ts +13 -0
  26. package/dist/server/mutations.js +476 -0
  27. package/dist/server/plugin.d.ts +47 -0
  28. package/dist/server/plugin.js +1 -0
  29. package/dist/server/pluginAccess.d.ts +7 -0
  30. package/dist/server/pluginAccess.js +79 -0
  31. package/dist/server/pluginRegistry.d.ts +12 -0
  32. package/dist/server/pluginRegistry.js +72 -0
  33. package/dist/server/query/listColumns.d.ts +19 -0
  34. package/dist/server/query/listColumns.js +43 -0
  35. package/dist/server/query/listQuery.d.ts +1 -1
  36. package/dist/server/query/pageSize.d.ts +19 -0
  37. package/dist/server/query/pageSize.js +26 -0
  38. package/dist/server/query/sortQuery.d.ts +31 -0
  39. package/dist/server/query/sortQuery.js +33 -0
  40. package/dist/server/query/urls.js +9 -1
  41. package/dist/server/relationLoaders.d.ts +42 -0
  42. package/dist/server/relationLoaders.js +188 -0
  43. package/dist/server/router.d.ts +10 -0
  44. package/dist/server/router.js +42 -19
  45. package/dist/server/runtime.d.ts +51 -0
  46. package/dist/server/runtime.js +263 -0
  47. package/dist/server/search.d.ts +14 -0
  48. package/dist/server/search.js +78 -0
  49. package/dist/server/submitted.d.ts +20 -0
  50. package/dist/server/submitted.js +54 -0
  51. package/dist/server/views/FieldInput.svelte +114 -8
  52. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  53. package/dist/server/views/Form.svelte +96 -5
  54. package/dist/server/views/Form.svelte.d.ts +19 -1
  55. package/dist/server/views/Layout.svelte +32 -4
  56. package/dist/server/views/Layout.svelte.d.ts +2 -0
  57. package/dist/server/views/List.svelte +156 -26
  58. package/dist/server/views/List.svelte.d.ts +7 -2
  59. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  60. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  61. package/dist/server/views/RelationSelect.svelte +14 -3
  62. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  63. package/dist/server/views/html.d.ts +19 -0
  64. package/dist/server/views/html.js +25 -0
  65. package/dist/server/views/pagination.d.ts +9 -0
  66. package/dist/server/views/pagination.js +31 -0
  67. package/dist/server/views/theme.js +147 -3
  68. package/dist/server/views/types.d.ts +15 -0
  69. 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. For Drizzle, pass an adapter from the subpath
50
- export (this does not pull in `drizzle-orm` for Prisma apps):
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
- async function selectByPrimaryKey(db, table, model, id) {
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(eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
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 = eq(primaryKeyColumn(table, model), coercedId);
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
- return (await selectByPrimaryKey(db, table, model, coercedId));
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
- return rows[0];
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
- return db
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(desc(primaryKey))
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 = columns[field];
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
- if (m2mFields.length === 0) {
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
- if (m2mFields.length === 0) {
173
- return updateAndReturn(db, table, model, id, input.scalars, ctx.dialect);
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
- const parent = updateAndReturnSqlite(tx, table, model, id, input.scalars);
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
- const parent = await updateAndReturn(tx, table, model, id, input.scalars, ctx.dialect);
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
- await db
210
- .delete(table)
211
- .where(eq(primaryKeyColumn(table, model), coercedId));
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
- tx.delete(table)
220
- .where(eq(primaryKeyColumn(table, model), coercedId))
221
- .run();
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
- await tx.delete(table).where(eq(primaryKeyColumn(table, model), coercedId));
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 type { DataAdapter, SchemaIntrospector } from "../types.js";
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: { [primaryKey]: 'desc' } }),
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
- if (m2mFields.length === 0) {
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 = { [primaryKey]: coerceId(String(id), model) };
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
- if (m2mFields.length === 0) {
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 deleteRecord(model, id) {
131
+ async deleteMany(model, ids, authorizationFilter) {
77
132
  const primaryKey = primaryKeyOf(model);
78
- await prisma[toPrismaModel(model.name)].delete({ where: { [primaryKey]: coerceId(String(id), model) } });
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';