sveltekit-admin 0.6.0 → 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.
Files changed (45) 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 +121 -36
  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 +52 -10
  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 +9 -2
  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/errors.d.ts +47 -0
  18. package/dist/server/errors.js +90 -0
  19. package/dist/server/handler.d.ts +57 -26
  20. package/dist/server/handler.js +212 -561
  21. package/dist/server/mutations.d.ts +9 -0
  22. package/dist/server/mutations.js +295 -0
  23. package/dist/server/plugin.d.ts +47 -0
  24. package/dist/server/plugin.js +1 -0
  25. package/dist/server/pluginAccess.d.ts +7 -0
  26. package/dist/server/pluginAccess.js +79 -0
  27. package/dist/server/pluginRegistry.d.ts +12 -0
  28. package/dist/server/pluginRegistry.js +72 -0
  29. package/dist/server/query/listQuery.d.ts +1 -1
  30. package/dist/server/relationLoaders.d.ts +42 -0
  31. package/dist/server/relationLoaders.js +188 -0
  32. package/dist/server/router.d.ts +10 -0
  33. package/dist/server/router.js +42 -19
  34. package/dist/server/runtime.d.ts +46 -0
  35. package/dist/server/runtime.js +210 -0
  36. package/dist/server/search.d.ts +14 -0
  37. package/dist/server/search.js +78 -0
  38. package/dist/server/views/Form.svelte +26 -2
  39. package/dist/server/views/Form.svelte.d.ts +2 -1
  40. package/dist/server/views/Layout.svelte +27 -2
  41. package/dist/server/views/Layout.svelte.d.ts +2 -0
  42. package/dist/server/views/List.svelte +28 -3
  43. package/dist/server/views/List.svelte.d.ts +2 -2
  44. package/dist/server/views/types.d.ts +8 -0
  45. package/package.json +23 -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 } 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,58 @@ 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
+ * Ordre de verrouillage déterministe. Les guards m2m sont empilés dans l'ordre
17
+ * des ids soumis par le formulaire, donc sous contrôle du client : deux
18
+ * requêtes concurrentes envoyant [1,2] et [2,1] verrouilleraient les mêmes
19
+ * lignes en sens inverse et se deadlockeraient (PostgreSQL 40P01), ce qui est
20
+ * bien plus facile à déclencher que la course qu'on cherche à empêcher.
21
+ * Trier sur (modèle, pk) donne un ordre total stable et supprime le cycle.
22
+ */
23
+ function orderedGuards(guards) {
24
+ return [...guards].sort((a, b) => a.targetModel.name.localeCompare(b.targetModel.name) ||
25
+ String(a.targetPk).localeCompare(String(b.targetPk)));
26
+ }
27
+ async function validateTargetGuards(ctx, tx, guards, compile) {
28
+ for (const guard of orderedGuards(guards)) {
29
+ const table = tableFor(ctx, guard.targetModel);
30
+ const where = and(eq(primaryKeyColumn(table, guard.targetModel), guard.targetPk), compile(table, guard.filter));
31
+ const query = tx.select().from(table).where(where).limit(1);
32
+ // Verrou partagé tenu jusqu'au commit, PostgreSQL uniquement.
33
+ //
34
+ // Mesuré (PG 16, les 4 combinaisons) : SERIALIZABLE seul n'annule PAS la
35
+ // séquence « lire le guard -> un tiers sort la cible du scope -> écrire ».
36
+ // SSI n'y voit aucun cycle de dépendances, donc cet ordre reste
37
+ // sérialisable et les deux transactions committent. Seul le verrou de
38
+ // ligne ferme la fenêtre. Ne pas le retirer en pensant que le niveau
39
+ // d'isolation suffit : c'est faux, et ça a été vérifié.
40
+ //
41
+ // MySQL est exclu volontairement : SERIALIZABLE y transforme déjà les
42
+ // SELECT en lectures verrouillantes (mesuré : le writer concurrent est
43
+ // bloqué), donc le verrou n'apporterait rien — et `for share` est une
44
+ // syntaxe 8.0+, l'émettre casserait les schémas encore en 5.7.
45
+ const rows = await (ctx.dialect === "postgresql" ? query.for("share") : query);
46
+ if (rows.length === 0)
47
+ throw new Error("relation target is outside the authorization scope");
48
+ }
49
+ }
50
+ function validateTargetGuardsSqlite(ctx, tx, guards, compile) {
51
+ // Pas de clause de verrou en SQLite (absente de sqlite-core, et inutile :
52
+ // l'écriture concurrente échoue fermée, cf. le commentaire ci-dessus).
53
+ for (const guard of orderedGuards(guards)) {
54
+ const table = tableFor(ctx, guard.targetModel);
55
+ const where = and(eq(primaryKeyColumn(table, guard.targetModel), guard.targetPk), compile(table, guard.filter));
56
+ const row = tx.select().from(table).where(where).limit(1).get();
57
+ if (!row)
58
+ throw new Error("relation target is outside the authorization scope");
59
+ }
60
+ }
61
+ async function selectByPrimaryKey(db, table, model, id, authorizationFilter) {
62
+ const primaryKeyWhere = eq(primaryKeyColumn(table, model), coerceId(String(id), model));
15
63
  const rows = await db
16
64
  .select()
17
65
  .from(table)
18
- .where(eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
66
+ .where(authorizationFilter ? and(primaryKeyWhere, authorizationFilter) : primaryKeyWhere)
19
67
  .limit(1);
20
68
  return rows[0] ?? null;
21
69
  }
@@ -32,15 +80,23 @@ async function insertAndReturn(db, table, model, scalars, dialect) {
32
80
  const rows = await db.insert(table).values(scalars).returning();
33
81
  return rows[0];
34
82
  }
35
- async function updateAndReturn(db, table, model, id, scalars, dialect) {
83
+ async function updateAndReturn(db, table, model, id, scalars, dialect, authorizationFilter) {
36
84
  const coercedId = coerceId(String(id), model);
37
- const where = eq(primaryKeyColumn(table, model), coercedId);
85
+ const where = authorizationFilter
86
+ ? and(eq(primaryKeyColumn(table, model), coercedId), authorizationFilter)
87
+ : eq(primaryKeyColumn(table, model), coercedId);
38
88
  if (dialect === "mysql") {
39
89
  await db.update(table).set(scalars).where(where);
40
- return (await selectByPrimaryKey(db, table, model, coercedId));
90
+ const row = await selectByPrimaryKey(db, table, model, coercedId, authorizationFilter);
91
+ if (!row)
92
+ throw new Error("record is outside the authorization scope");
93
+ return row;
41
94
  }
42
95
  const rows = await db.update(table).set(scalars).where(where).returning();
43
- return rows[0];
96
+ const row = rows[0];
97
+ if (!row)
98
+ throw new Error("record is outside the authorization scope");
99
+ return row;
44
100
  }
45
101
  async function insertM2mRows(db, link, parentId, ids) {
46
102
  if (ids.length === 0)
@@ -53,13 +109,16 @@ async function insertM2mRows(db, link, parentId, ids) {
53
109
  function insertAndReturnSqlite(db, table, scalars) {
54
110
  return db.insert(table).values(scalars).returning().get();
55
111
  }
56
- function updateAndReturnSqlite(db, table, model, id, scalars) {
57
- return db
112
+ function updateAndReturnSqlite(db, table, model, id, scalars, authorizationFilter) {
113
+ const row = db
58
114
  .update(table)
59
115
  .set(scalars)
60
- .where(eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
116
+ .where(authorizationFilter ? and(eq(primaryKeyColumn(table, model), coerceId(String(id), model)), authorizationFilter) : eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
61
117
  .returning()
62
118
  .get();
119
+ if (!row)
120
+ throw new Error("record is outside the authorization scope");
121
+ return row;
63
122
  }
64
123
  function insertM2mRowsSqlite(db, link, parentId, ids) {
65
124
  if (ids.length === 0)
@@ -138,11 +197,13 @@ export function createDrizzleDataAdapter(db, ctx) {
138
197
  async createRecord(model, input) {
139
198
  const table = tableFor(ctx, model);
140
199
  const m2mFields = Object.entries(input.m2m ?? {});
141
- if (m2mFields.length === 0) {
200
+ const guards = input.targetGuards ?? [];
201
+ if (m2mFields.length === 0 && guards.length === 0) {
142
202
  return insertAndReturn(db, table, model, input.scalars, ctx.dialect);
143
203
  }
144
204
  if (ctx.dialect === "sqlite") {
145
205
  return db.transaction((tx) => {
206
+ validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
146
207
  const parent = insertAndReturnSqlite(tx, table, input.scalars);
147
208
  const parentId = parent[primaryKeyOf(model)];
148
209
  for (const [field, relation] of m2mFields) {
@@ -152,9 +213,10 @@ export function createDrizzleDataAdapter(db, ctx) {
152
213
  insertM2mRowsSqlite(tx, link, parentId, relation.ids);
153
214
  }
154
215
  return parent;
155
- });
216
+ }, { behavior: "immediate" });
156
217
  }
157
- return db.transaction(async (tx) => {
218
+ return withWriteRetry(() => db.transaction(async (tx) => {
219
+ await validateTargetGuards(ctx, tx, guards, compileHere);
158
220
  const parent = await insertAndReturn(tx, table, model, input.scalars, ctx.dialect);
159
221
  const parentId = parent[primaryKeyOf(model)];
160
222
  for (const [field, relation] of m2mFields) {
@@ -164,17 +226,19 @@ export function createDrizzleDataAdapter(db, ctx) {
164
226
  await insertM2mRows(tx, link, parentId, relation.ids);
165
227
  }
166
228
  return parent;
167
- });
229
+ }, { isolationLevel: "serializable" }));
168
230
  },
169
- async updateRecord(model, id, input) {
231
+ async updateRecord(model, id, input, authorizationFilter) {
170
232
  const table = tableFor(ctx, model);
171
233
  const m2mFields = Object.entries(input.m2m ?? {});
172
- if (m2mFields.length === 0) {
173
- return updateAndReturn(db, table, model, id, input.scalars, ctx.dialect);
234
+ const guards = input.targetGuards ?? [];
235
+ if (m2mFields.length === 0 && guards.length === 0) {
236
+ return updateAndReturn(db, table, model, id, input.scalars, ctx.dialect, compileHere(table, authorizationFilter));
174
237
  }
175
238
  if (ctx.dialect === "sqlite") {
176
239
  return db.transaction((tx) => {
177
- const parent = updateAndReturnSqlite(tx, table, model, id, input.scalars);
240
+ validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
241
+ const parent = updateAndReturnSqlite(tx, table, model, id, input.scalars, compileHere(table, authorizationFilter));
178
242
  const parentId = coerceId(String(id), model);
179
243
  for (const [field, relation] of m2mFields) {
180
244
  const link = ctx.m2m.get(`${model.name}.${field}`);
@@ -184,10 +248,11 @@ export function createDrizzleDataAdapter(db, ctx) {
184
248
  insertM2mRowsSqlite(tx, link, parentId, relation.ids);
185
249
  }
186
250
  return parent;
187
- });
251
+ }, { behavior: "immediate" });
188
252
  }
189
- return db.transaction(async (tx) => {
190
- const parent = await updateAndReturn(tx, table, model, id, input.scalars, ctx.dialect);
253
+ return withWriteRetry(() => db.transaction(async (tx) => {
254
+ await validateTargetGuards(ctx, tx, guards, compileHere);
255
+ const parent = await updateAndReturn(tx, table, model, id, input.scalars, ctx.dialect, compileHere(table, authorizationFilter));
191
256
  const parentId = coerceId(String(id), model);
192
257
  for (const [field, relation] of m2mFields) {
193
258
  const link = ctx.m2m.get(`${model.name}.${field}`);
@@ -197,37 +262,57 @@ export function createDrizzleDataAdapter(db, ctx) {
197
262
  await insertM2mRows(tx, link, parentId, relation.ids);
198
263
  }
199
264
  return parent;
200
- });
265
+ }, { isolationLevel: "serializable" }));
201
266
  },
202
- async deleteRecord(model, id) {
267
+ async deleteRecord(model, id, authorizationFilter) {
203
268
  const table = tableFor(ctx, model);
204
269
  const coercedId = coerceId(String(id), model);
205
270
  const links = [...ctx.m2m.entries()]
206
271
  .filter(([key]) => key.startsWith(`${model.name}.`))
207
272
  .map(([, link]) => link);
273
+ const parentWhere = and(eq(primaryKeyColumn(table, model), coercedId), compileHere(table, authorizationFilter));
208
274
  if (links.length === 0) {
209
- await db
210
- .delete(table)
211
- .where(eq(primaryKeyColumn(table, model), coercedId));
275
+ if (ctx.dialect === "sqlite") {
276
+ const result = db.delete(table).where(parentWhere).run();
277
+ if (result.changes !== 1)
278
+ throw new Error("record is outside the authorization scope");
279
+ }
280
+ else {
281
+ const result = await db.delete(table).where(parentWhere);
282
+ if (Number(result?.affectedRows ?? 0) !== 1)
283
+ throw new Error("record is outside the authorization scope");
284
+ }
212
285
  return;
213
286
  }
287
+ // Les pivots partent avant le parent, l'ordre qu'imposent les FK. Le DELETE
288
+ // scopé du parent sert lui-même de garde : zéro ligne touchée => throw =>
289
+ // rollback des pivots. Pas de SELECT de vérification préalable, donc aucune
290
+ // fenêtre TOCTOU entre la lecture du scope et la suppression, et aucune
291
+ // branche défensive inatteignable.
214
292
  if (ctx.dialect === "sqlite") {
215
293
  db.transaction((tx) => {
216
- for (const link of links) {
294
+ for (const link of links)
217
295
  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
- });
296
+ const result = tx.delete(table).where(parentWhere).run();
297
+ if (result.changes !== 1)
298
+ throw new Error("record is outside the authorization scope");
299
+ }, { behavior: "immediate" });
223
300
  return;
224
301
  }
225
- await db.transaction(async (tx) => {
226
- for (const link of links) {
302
+ await withWriteRetry(() => db.transaction(async (tx) => {
303
+ for (const link of links)
227
304
  await tx.delete(link.pivot).where(eq(link.selfColumn, coercedId));
305
+ if (ctx.dialect === "postgresql") {
306
+ const deleted = await tx.delete(table).where(parentWhere).returning({ id: primaryKeyColumn(table, model) });
307
+ if (deleted.length !== 1)
308
+ throw new Error("record is outside the authorization scope");
228
309
  }
229
- await tx.delete(table).where(eq(primaryKeyColumn(table, model), coercedId));
230
- });
310
+ else {
311
+ const result = await tx.delete(table).where(parentWhere);
312
+ if (Number(result?.affectedRows ?? 0) !== 1)
313
+ throw new Error("record is outside the authorization scope");
314
+ }
315
+ }, { isolationLevel: "serializable" }));
231
316
  },
232
317
  async getM2mSelectedIds(model, edge, _targetModel, recordId) {
233
318
  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
@@ -44,38 +77,47 @@ export function createPrismaDataAdapter(prisma, opts = {}) {
44
77
  async createRecord(model, input) {
45
78
  const key = toPrismaModel(model.name);
46
79
  const m2mFields = Object.keys(input.m2m ?? {});
47
- if (m2mFields.length === 0) {
80
+ const guards = input.targetGuards ?? [];
81
+ if (m2mFields.length === 0 && guards.length === 0) {
48
82
  return prisma[key].create({ data: input.scalars });
49
83
  }
50
- return prisma.$transaction(async (tx) => {
84
+ return withWriteRetry(() => prisma.$transaction(async (tx) => {
85
+ await validateTargetGuards(tx, guards, compileHere);
51
86
  const data = { ...input.scalars };
52
87
  for (const field of m2mFields) {
53
88
  const { targetPkField, ids } = input.m2m[field];
54
89
  data[field] = { connect: ids.map((id) => ({ [targetPkField]: id })) };
55
90
  }
56
91
  return tx[key].create({ data });
57
- });
92
+ }, { isolationLevel: "Serializable" }));
58
93
  },
59
- async updateRecord(model, id, input) {
94
+ async updateRecord(model, id, input, authorizationFilter) {
60
95
  const key = toPrismaModel(model.name);
61
96
  const primaryKey = primaryKeyOf(model);
62
- const where = { [primaryKey]: coerceId(String(id), model) };
97
+ const where = authorizationFilter
98
+ ? { [primaryKey]: coerceId(String(id), model), AND: [compileHere(authorizationFilter)] }
99
+ : { [primaryKey]: coerceId(String(id), model) };
63
100
  const m2mFields = Object.keys(input.m2m ?? {});
64
- if (m2mFields.length === 0) {
101
+ const guards = input.targetGuards ?? [];
102
+ if (m2mFields.length === 0 && guards.length === 0) {
65
103
  return prisma[key].update({ where, data: input.scalars });
66
104
  }
67
- return prisma.$transaction(async (tx) => {
105
+ return withWriteRetry(() => prisma.$transaction(async (tx) => {
106
+ await validateTargetGuards(tx, guards, compileHere);
68
107
  const data = { ...input.scalars };
69
108
  for (const field of m2mFields) {
70
109
  const { targetPkField, ids } = input.m2m[field];
71
110
  data[field] = { set: ids.map((id) => ({ [targetPkField]: id })) };
72
111
  }
73
112
  return tx[key].update({ where, data });
74
- });
113
+ }, { isolationLevel: "Serializable" }));
75
114
  },
76
- async deleteRecord(model, id) {
115
+ async deleteRecord(model, id, authorizationFilter) {
77
116
  const primaryKey = primaryKeyOf(model);
78
- await prisma[toPrismaModel(model.name)].delete({ where: { [primaryKey]: coerceId(String(id), model) } });
117
+ const where = authorizationFilter
118
+ ? { [primaryKey]: coerceId(String(id), model), AND: [compileHere(authorizationFilter)] }
119
+ : { [primaryKey]: coerceId(String(id), model) };
120
+ await prisma[toPrismaModel(model.name)].delete({ where });
79
121
  },
80
122
  async getM2mSelectedIds(model, edge, targetModel, recordId) {
81
123
  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';
@@ -0,0 +1,37 @@
1
+ import { createAdminHandler as createCoreHandler } from '../../handler.js';
2
+ import { createPrismaDataAdapter } from './dataAdapter.js';
3
+ import { createPrismaIntrospector } from './introspector.js';
4
+ import { resolveCaseInsensitiveSearch } from './index.js';
5
+ function omitPrismaShortcutFields(config) {
6
+ const { prisma: _prisma, prismaSchemaPath: _path, search: _search, adapter: _adapter, ...rest } = config;
7
+ return rest;
8
+ }
9
+ function buildPrismaAdapter(config) {
10
+ const schemaPath = config.prismaSchemaPath ?? './prisma/schema.prisma';
11
+ const introspector = createPrismaIntrospector({ schemaPath });
12
+ let schema = null;
13
+ try {
14
+ schema = introspector.introspect();
15
+ }
16
+ catch {
17
+ schema = null;
18
+ }
19
+ return {
20
+ introspector: schema ? { introspect: () => schema } : introspector,
21
+ data: createPrismaDataAdapter(config.prisma, {
22
+ caseInsensitiveSearch: resolveCaseInsensitiveSearch(schema, config.search?.mode)
23
+ })
24
+ };
25
+ }
26
+ export function createAdminHandler(config) {
27
+ if (config.adapter) {
28
+ return createCoreHandler({ ...omitPrismaShortcutFields(config), adapter: config.adapter });
29
+ }
30
+ if (!config.prisma) {
31
+ throw new Error('[sveltekit-admin] createAdminHandler requires either `prisma` (with optional `prismaSchemaPath`) or `adapter` — neither was provided.');
32
+ }
33
+ return createCoreHandler({
34
+ ...omitPrismaShortcutFields(config),
35
+ adapter: buildPrismaAdapter(config)
36
+ });
37
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Reprise bornée des écritures transactionnelles.
3
+ *
4
+ * Une transaction `SERIALIZABLE` peut échouer sur un conflit que le moteur ne
5
+ * sait pas ordonner (PostgreSQL 40001) ou sur un deadlock (PostgreSQL 40P01,
6
+ * MySQL 1213). Ces échecs sont transitoires par construction : la transaction
7
+ * a été annulée entièrement, elle n'a donc rien écrit, et rejouer le même
8
+ * travail sur un instantané neuf aboutit presque toujours. Sans reprise ils
9
+ * remontent en 500 alors que rien n'est cassé.
10
+ *
11
+ * On ne rejoue QUE ces codes. Un refus de scope (« outside the authorization
12
+ * scope ») ou une FK invalide ne sont pas transitoires : les rejouer ne ferait
13
+ * que répéter le même refus, et masquerait un refus légitime derrière une
14
+ * latence. Le défaut est donc de laisser remonter.
15
+ *
16
+ * Pas de temporisation entre les tentatives : au moment où le moteur signale
17
+ * le conflit, la transaction concurrente est déjà terminée (committée ou
18
+ * annulée), donc attendre ne change rien à la probabilité de succès. Cela
19
+ * évite aussi d'introduire des minuteurs dans un chemin d'écriture.
20
+ */
21
+ export declare function isRetryableWriteError(error: unknown): boolean;
22
+ /**
23
+ * Exécute `run`, en le rejouant tant que l'échec est un conflit de concurrence
24
+ * et que le budget de tentatives n'est pas épuisé. `attempts` compte la
25
+ * tentative initiale : 3 signifie « un essai puis deux reprises ».
26
+ */
27
+ export declare function withWriteRetry<T>(run: () => Promise<T>, attempts?: number): Promise<T>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reprise bornée des écritures transactionnelles.
3
+ *
4
+ * Une transaction `SERIALIZABLE` peut échouer sur un conflit que le moteur ne
5
+ * sait pas ordonner (PostgreSQL 40001) ou sur un deadlock (PostgreSQL 40P01,
6
+ * MySQL 1213). Ces échecs sont transitoires par construction : la transaction
7
+ * a été annulée entièrement, elle n'a donc rien écrit, et rejouer le même
8
+ * travail sur un instantané neuf aboutit presque toujours. Sans reprise ils
9
+ * remontent en 500 alors que rien n'est cassé.
10
+ *
11
+ * On ne rejoue QUE ces codes. Un refus de scope (« outside the authorization
12
+ * scope ») ou une FK invalide ne sont pas transitoires : les rejouer ne ferait
13
+ * que répéter le même refus, et masquerait un refus légitime derrière une
14
+ * latence. Le défaut est donc de laisser remonter.
15
+ *
16
+ * Pas de temporisation entre les tentatives : au moment où le moteur signale
17
+ * le conflit, la transaction concurrente est déjà terminée (committée ou
18
+ * annulée), donc attendre ne change rien à la probabilité de succès. Cela
19
+ * évite aussi d'introduire des minuteurs dans un chemin d'écriture.
20
+ */
21
+ import { codeOf } from '../errors.js';
22
+ /** Codes que le moteur n'émet que pour un conflit de concurrence annulable. */
23
+ const RETRYABLE_CODES = new Set([
24
+ '40001', // PostgreSQL / CockroachDB — serialization_failure
25
+ '40P01', // PostgreSQL — deadlock_detected
26
+ 'ER_LOCK_DEADLOCK', // MySQL 1213
27
+ 'ER_LOCK_WAIT_TIMEOUT' // MySQL 1205
28
+ ]);
29
+ export function isRetryableWriteError(error) {
30
+ const code = codeOf(error);
31
+ return code !== undefined && RETRYABLE_CODES.has(code);
32
+ }
33
+ /**
34
+ * Exécute `run`, en le rejouant tant que l'échec est un conflit de concurrence
35
+ * et que le budget de tentatives n'est pas épuisé. `attempts` compte la
36
+ * tentative initiale : 3 signifie « un essai puis deux reprises ».
37
+ */
38
+ export async function withWriteRetry(run, attempts = 3) {
39
+ // La boucle ne couvre que les reprises ; la dernière tentative est le `run`
40
+ // final, dont l'échec remonte tel quel. Écrit ainsi plutôt qu'en boucle
41
+ // infinie avec un `throw` de sortie : celle-ci n'aurait aucune sortie
42
+ // normale, donc une branche inatteignable et non testable.
43
+ for (let remaining = attempts - 1; remaining > 0; remaining -= 1) {
44
+ try {
45
+ return await run();
46
+ }
47
+ catch (error) {
48
+ if (!isRetryableWriteError(error))
49
+ throw error;
50
+ }
51
+ }
52
+ return run();
53
+ }