sveltekit-admin 0.5.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +89 -1
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +2 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
  5. package/dist/server/adapters/drizzle/dataAdapter.js +328 -0
  6. package/dist/server/adapters/drizzle/filterCompiler.d.ts +8 -0
  7. package/dist/server/adapters/drizzle/filterCompiler.js +61 -0
  8. package/dist/server/adapters/drizzle/index.d.ts +22 -0
  9. package/dist/server/adapters/drizzle/index.js +27 -0
  10. package/dist/server/adapters/drizzle/inspect.d.ts +23 -0
  11. package/dist/server/adapters/drizzle/inspect.js +220 -0
  12. package/dist/server/adapters/drizzle/introspector.d.ts +3 -0
  13. package/dist/server/adapters/drizzle/introspector.js +3 -0
  14. package/dist/server/adapters/filter.d.ts +12 -0
  15. package/dist/server/adapters/filter.js +72 -0
  16. package/dist/server/adapters/prisma/dataAdapter.d.ts +13 -0
  17. package/dist/server/adapters/prisma/dataAdapter.js +138 -0
  18. package/dist/server/adapters/prisma/filterCompiler.d.ts +9 -0
  19. package/dist/server/adapters/prisma/filterCompiler.js +42 -0
  20. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  21. package/dist/server/adapters/prisma/handler.js +37 -0
  22. package/dist/server/adapters/prisma/index.d.ts +28 -0
  23. package/dist/server/adapters/prisma/index.js +32 -0
  24. package/dist/server/adapters/prisma/introspector.d.ts +4 -0
  25. package/dist/server/adapters/prisma/introspector.js +9 -0
  26. package/dist/server/adapters/retry.d.ts +27 -0
  27. package/dist/server/adapters/retry.js +53 -0
  28. package/dist/server/adapters/types.d.ts +86 -0
  29. package/dist/server/adapters/types.js +1 -0
  30. package/dist/server/audit.d.ts +65 -0
  31. package/dist/server/audit.js +106 -0
  32. package/dist/server/csrf.d.ts +33 -0
  33. package/dist/server/csrf.js +55 -0
  34. package/dist/server/data.d.ts +0 -8
  35. package/dist/server/data.js +0 -41
  36. package/dist/server/errors.d.ts +47 -0
  37. package/dist/server/errors.js +90 -0
  38. package/dist/server/handler.d.ts +63 -16
  39. package/dist/server/handler.js +212 -535
  40. package/dist/server/introspection/parser.d.ts +4 -40
  41. package/dist/server/introspection/relations.d.ts +1 -1
  42. package/dist/server/introspection/relations.js +1 -1
  43. package/dist/server/mutations.d.ts +9 -0
  44. package/dist/server/mutations.js +295 -0
  45. package/dist/server/plugin.d.ts +47 -0
  46. package/dist/server/plugin.js +1 -0
  47. package/dist/server/pluginAccess.d.ts +7 -0
  48. package/dist/server/pluginAccess.js +79 -0
  49. package/dist/server/pluginRegistry.d.ts +12 -0
  50. package/dist/server/pluginRegistry.js +72 -0
  51. package/dist/server/query/listQuery.d.ts +6 -12
  52. package/dist/server/query/listQuery.js +31 -53
  53. package/dist/server/relationLoaders.d.ts +42 -0
  54. package/dist/server/relationLoaders.js +188 -0
  55. package/dist/server/router.d.ts +10 -0
  56. package/dist/server/router.js +42 -19
  57. package/dist/server/runtime.d.ts +46 -0
  58. package/dist/server/runtime.js +210 -0
  59. package/dist/server/search.d.ts +14 -0
  60. package/dist/server/search.js +78 -0
  61. package/dist/server/types/schema.d.ts +40 -0
  62. package/dist/server/types/schema.js +8 -0
  63. package/dist/server/views/Form.svelte +27 -3
  64. package/dist/server/views/Form.svelte.d.ts +2 -1
  65. package/dist/server/views/Layout.svelte +27 -2
  66. package/dist/server/views/Layout.svelte.d.ts +2 -0
  67. package/dist/server/views/List.svelte +28 -3
  68. package/dist/server/views/List.svelte.d.ts +2 -2
  69. package/dist/server/views/RelationCheckboxes.svelte +1 -1
  70. package/dist/server/views/types.d.ts +8 -0
  71. package/package.json +40 -17
package/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  ![Version](https://img.shields.io/npm/v/sveltekit-admin)
6
6
  ![License](https://img.shields.io/npm/l/sveltekit-admin)
7
7
 
8
- See [CHANGELOG.md](./CHANGELOG.md) for release notes and breaking changes.
8
+ See [CHANGELOG.md](./CHANGELOG.md) for release notes and breaking changes, and
9
+ [CONTRIBUTING.md](./CONTRIBUTING.md) for the versioning policy and release process.
9
10
 
10
11
  ## Features
11
12
 
@@ -15,6 +16,9 @@ See [CHANGELOG.md](./CHANGELOG.md) for release notes and breaking changes.
15
16
  - ⚡ **Zero routes** - everything handled via a single hook
16
17
  - ðŸŠķ **3 lines of code** to setup
17
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: []`)
21
+ - 🔌 **Drizzle adapter** (optional subpath export)
18
22
 
19
23
  ## Installation
20
24
 
@@ -42,6 +46,39 @@ That's it! Navigate to `/admin` and you'll see:
42
46
  - Create/Edit forms auto-generated from your Prisma schema
43
47
  - Delete with confirmation
44
48
 
49
+ ## Drizzle
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:
54
+
55
+ ```typescript
56
+ import { createAdminHandler, createDrizzleAdapter } from 'sveltekit-admin/adapters/drizzle';
57
+ import { db } from './db';
58
+ import * as schema from './db/schema';
59
+
60
+ export const handle = createAdminHandler({
61
+ adapter: createDrizzleAdapter({ db, schema }),
62
+ authCheck: (event) => event.locals.session?.user?.role === 'admin'
63
+ });
64
+ ```
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
+
70
+ Pass the same `schema` object you already export (tables + `relations()`).
71
+ Model names in `config.models` are the JS export keys (`users`, not `User`).
72
+ `config.search.mode` only applies to `createAdminHandler({ prisma })`;
73
+ pass `searchMode` to `createDrizzleAdapter` instead. Nested Prisma `where`
74
+ objects in `listWhere` are not supported — use a flat `{ tenantId: 1 }` or
75
+ a `Filter` AST.
76
+
77
+ Many-to-many writes on SQLite currently require better-sqlite3's synchronous
78
+ `db.transaction` with `.get()` and `.run()`. Async SQLite drivers such as
79
+ libsql/Turso, D1, and `bun:sqlite` are not supported for many-to-many writes in
80
+ this version; PostgreSQL and MySQL use async transactions.
81
+
45
82
  ## Configuration
46
83
 
47
84
  ```typescript
@@ -132,6 +169,37 @@ prefetch would allow), and this route is checked *before* `authCheck`, so
132
169
  a user whose session already expired can still use it to clean up
133
170
  client-side state instead of being stuck behind a 401 with no way back.
134
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
+
135
203
  ## How It Works
136
204
 
137
205
  The admin handler intercepts all requests to `/admin/*` and:
@@ -146,6 +214,26 @@ Routes handled:
146
214
  - `/admin/user/new` → Create user form
147
215
  - `/admin/user/123` → Edit user form
148
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
+
149
237
  ## Model Configuration
150
238
 
151
239
  ```typescript
package/dist/index.d.ts CHANGED
@@ -2,6 +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';
9
+ export type { Schema, Model, Field } from './server/types/schema.js';
10
+ export { createPrismaAdapter } from './server/adapters/prisma/index.js';
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,6 +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
+ export { createPrismaAdapter } from './server/adapters/prisma/index.js';
@@ -0,0 +1,11 @@
1
+ import type { Table } from "drizzle-orm";
2
+ import type { DataAdapter } from "../types.js";
3
+ import type { DrizzleDialect, M2mLink } from "./inspect.js";
4
+ interface DrizzleDataAdapterContext {
5
+ tables: Record<string, Table>;
6
+ m2m: Map<string, M2mLink>;
7
+ dialect: DrizzleDialect;
8
+ caseInsensitiveSearch: boolean;
9
+ }
10
+ export declare function createDrizzleDataAdapter(db: any, ctx: DrizzleDataAdapterContext): DataAdapter;
11
+ export {};
@@ -0,0 +1,328 @@
1
+ import { and, asc, count, desc, eq, getTableColumns } from "drizzle-orm";
2
+ import { coerceId, primaryKeyOf } from "../../data.js";
3
+ import { withWriteRetry } from "../retry.js";
4
+ import { compileFilterToDrizzle } from "./filterCompiler.js";
5
+ function tableFor(ctx, model) {
6
+ const table = ctx.tables[model.name];
7
+ if (!table) {
8
+ throw new Error(`[sveltekit-admin] missing Drizzle table '${model.name}'`);
9
+ }
10
+ return table;
11
+ }
12
+ function primaryKeyColumn(table, model) {
13
+ return getTableColumns(table)[primaryKeyOf(model)];
14
+ }
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));
63
+ const rows = await db
64
+ .select()
65
+ .from(table)
66
+ .where(authorizationFilter ? and(primaryKeyWhere, authorizationFilter) : primaryKeyWhere)
67
+ .limit(1);
68
+ return rows[0] ?? null;
69
+ }
70
+ async function insertAndReturn(db, table, model, scalars, dialect) {
71
+ if (dialect === "mysql") {
72
+ const ids = await db.insert(table).values(scalars).$returningId();
73
+ const primaryKey = primaryKeyOf(model);
74
+ const rows = await db
75
+ .select()
76
+ .from(table)
77
+ .where(eq(primaryKeyColumn(table, model), ids[0][primaryKey]));
78
+ return rows[0];
79
+ }
80
+ const rows = await db.insert(table).values(scalars).returning();
81
+ return rows[0];
82
+ }
83
+ async function updateAndReturn(db, table, model, id, scalars, dialect, authorizationFilter) {
84
+ const coercedId = coerceId(String(id), model);
85
+ const where = authorizationFilter
86
+ ? and(eq(primaryKeyColumn(table, model), coercedId), authorizationFilter)
87
+ : eq(primaryKeyColumn(table, model), coercedId);
88
+ if (dialect === "mysql") {
89
+ await db.update(table).set(scalars).where(where);
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;
94
+ }
95
+ const rows = await db.update(table).set(scalars).where(where).returning();
96
+ const row = rows[0];
97
+ if (!row)
98
+ throw new Error("record is outside the authorization scope");
99
+ return row;
100
+ }
101
+ async function insertM2mRows(db, link, parentId, ids) {
102
+ if (ids.length === 0)
103
+ return;
104
+ await db.insert(link.pivot).values(ids.map((id) => ({
105
+ [link.selfKey]: parentId,
106
+ [link.otherKey]: id,
107
+ })));
108
+ }
109
+ function insertAndReturnSqlite(db, table, scalars) {
110
+ return db.insert(table).values(scalars).returning().get();
111
+ }
112
+ function updateAndReturnSqlite(db, table, model, id, scalars, authorizationFilter) {
113
+ const row = db
114
+ .update(table)
115
+ .set(scalars)
116
+ .where(authorizationFilter ? and(eq(primaryKeyColumn(table, model), coerceId(String(id), model)), authorizationFilter) : eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
117
+ .returning()
118
+ .get();
119
+ if (!row)
120
+ throw new Error("record is outside the authorization scope");
121
+ return row;
122
+ }
123
+ function insertM2mRowsSqlite(db, link, parentId, ids) {
124
+ if (ids.length === 0)
125
+ return;
126
+ db.insert(link.pivot)
127
+ .values(ids.map((id) => ({
128
+ [link.selfKey]: parentId,
129
+ [link.otherKey]: id,
130
+ })))
131
+ .run();
132
+ }
133
+ export function createDrizzleDataAdapter(db, ctx) {
134
+ const compileHere = (table, filter) => compileFilterToDrizzle(table, filter, {
135
+ caseInsensitiveSearch: ctx.caseInsensitiveSearch,
136
+ dialect: ctx.dialect,
137
+ });
138
+ return {
139
+ async listRecords(model, opts) {
140
+ const table = tableFor(ctx, model);
141
+ const where = compileHere(table, opts.filter);
142
+ const primaryKey = primaryKeyColumn(table, model);
143
+ const [rows, totals] = await Promise.all([
144
+ db
145
+ .select()
146
+ .from(table)
147
+ .where(where)
148
+ .orderBy(desc(primaryKey))
149
+ .limit(opts.take)
150
+ .offset(opts.skip),
151
+ db.select({ n: count() }).from(table).where(where),
152
+ ]);
153
+ return { rows, total: totals[0].n };
154
+ },
155
+ async findMany(model, opts) {
156
+ const table = tableFor(ctx, model);
157
+ const columns = getTableColumns(table);
158
+ const orderBy = Object.entries(opts.orderBy ?? {}).map(([field, direction]) => {
159
+ const column = columns[field];
160
+ if (!column) {
161
+ throw new Error(`[sveltekit-admin] unknown field '${field}' on Drizzle table`);
162
+ }
163
+ return direction === "asc" ? asc(column) : desc(column);
164
+ });
165
+ let query = db
166
+ .select()
167
+ .from(table)
168
+ .where(compileHere(table, opts.filter));
169
+ if (orderBy.length > 0)
170
+ query = query.orderBy(...orderBy);
171
+ if (opts.take !== undefined)
172
+ query = query.limit(opts.take);
173
+ if (opts.skip !== undefined)
174
+ query = query.offset(opts.skip);
175
+ return query;
176
+ },
177
+ async getRecord(model, id) {
178
+ return selectByPrimaryKey(db, tableFor(ctx, model), model, id);
179
+ },
180
+ async findFirst(model, filter) {
181
+ const table = tableFor(ctx, model);
182
+ const rows = await db
183
+ .select()
184
+ .from(table)
185
+ .where(compileHere(table, filter))
186
+ .limit(1);
187
+ return rows[0] ?? null;
188
+ },
189
+ async countRecords(model, filter) {
190
+ const table = tableFor(ctx, model);
191
+ const rows = await db
192
+ .select({ n: count() })
193
+ .from(table)
194
+ .where(compileHere(table, filter));
195
+ return rows[0].n;
196
+ },
197
+ async createRecord(model, input) {
198
+ const table = tableFor(ctx, model);
199
+ const m2mFields = Object.entries(input.m2m ?? {});
200
+ const guards = input.targetGuards ?? [];
201
+ if (m2mFields.length === 0 && guards.length === 0) {
202
+ return insertAndReturn(db, table, model, input.scalars, ctx.dialect);
203
+ }
204
+ if (ctx.dialect === "sqlite") {
205
+ return db.transaction((tx) => {
206
+ validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
207
+ const parent = insertAndReturnSqlite(tx, table, input.scalars);
208
+ const parentId = parent[primaryKeyOf(model)];
209
+ for (const [field, relation] of m2mFields) {
210
+ const link = ctx.m2m.get(`${model.name}.${field}`);
211
+ if (!link)
212
+ continue;
213
+ insertM2mRowsSqlite(tx, link, parentId, relation.ids);
214
+ }
215
+ return parent;
216
+ }, { behavior: "immediate" });
217
+ }
218
+ return withWriteRetry(() => db.transaction(async (tx) => {
219
+ await validateTargetGuards(ctx, tx, guards, compileHere);
220
+ const parent = await insertAndReturn(tx, table, model, input.scalars, ctx.dialect);
221
+ const parentId = parent[primaryKeyOf(model)];
222
+ for (const [field, relation] of m2mFields) {
223
+ const link = ctx.m2m.get(`${model.name}.${field}`);
224
+ if (!link)
225
+ continue;
226
+ await insertM2mRows(tx, link, parentId, relation.ids);
227
+ }
228
+ return parent;
229
+ }, { isolationLevel: "serializable" }));
230
+ },
231
+ async updateRecord(model, id, input, authorizationFilter) {
232
+ const table = tableFor(ctx, model);
233
+ const m2mFields = Object.entries(input.m2m ?? {});
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));
237
+ }
238
+ if (ctx.dialect === "sqlite") {
239
+ return db.transaction((tx) => {
240
+ validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
241
+ const parent = updateAndReturnSqlite(tx, table, model, id, input.scalars, compileHere(table, authorizationFilter));
242
+ const parentId = coerceId(String(id), model);
243
+ for (const [field, relation] of m2mFields) {
244
+ const link = ctx.m2m.get(`${model.name}.${field}`);
245
+ if (!link)
246
+ continue;
247
+ tx.delete(link.pivot).where(eq(link.selfColumn, parentId)).run();
248
+ insertM2mRowsSqlite(tx, link, parentId, relation.ids);
249
+ }
250
+ return parent;
251
+ }, { behavior: "immediate" });
252
+ }
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));
256
+ const parentId = coerceId(String(id), model);
257
+ for (const [field, relation] of m2mFields) {
258
+ const link = ctx.m2m.get(`${model.name}.${field}`);
259
+ if (!link)
260
+ continue;
261
+ await tx.delete(link.pivot).where(eq(link.selfColumn, parentId));
262
+ await insertM2mRows(tx, link, parentId, relation.ids);
263
+ }
264
+ return parent;
265
+ }, { isolationLevel: "serializable" }));
266
+ },
267
+ async deleteRecord(model, id, authorizationFilter) {
268
+ const table = tableFor(ctx, model);
269
+ const coercedId = coerceId(String(id), model);
270
+ const links = [...ctx.m2m.entries()]
271
+ .filter(([key]) => key.startsWith(`${model.name}.`))
272
+ .map(([, link]) => link);
273
+ const parentWhere = and(eq(primaryKeyColumn(table, model), coercedId), compileHere(table, authorizationFilter));
274
+ if (links.length === 0) {
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
+ }
285
+ return;
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.
292
+ if (ctx.dialect === "sqlite") {
293
+ db.transaction((tx) => {
294
+ for (const link of links)
295
+ tx.delete(link.pivot).where(eq(link.selfColumn, coercedId)).run();
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" });
300
+ return;
301
+ }
302
+ await withWriteRetry(() => db.transaction(async (tx) => {
303
+ for (const link of links)
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");
309
+ }
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" }));
316
+ },
317
+ async getM2mSelectedIds(model, edge, _targetModel, recordId) {
318
+ const link = ctx.m2m.get(`${model.name}.${edge.field}`);
319
+ if (!link)
320
+ return [];
321
+ const rows = await db
322
+ .select({ id: link.otherColumn })
323
+ .from(link.pivot)
324
+ .where(eq(link.selfColumn, coerceId(String(recordId), model)));
325
+ return rows.map((row) => row.id);
326
+ },
327
+ };
328
+ }
@@ -0,0 +1,8 @@
1
+ import type { SQL, Table } from "drizzle-orm";
2
+ import type { Filter } from "../types.js";
3
+ import type { DrizzleDialect } from "./inspect.js";
4
+ export { OPAQUE_FILTER_ERROR } from "../filter.js";
5
+ export declare function compileFilterToDrizzle(table: Table, filter: Filter | Record<string, unknown> | undefined, opts: {
6
+ caseInsensitiveSearch: boolean;
7
+ dialect: DrizzleDialect;
8
+ }): SQL | undefined;
@@ -0,0 +1,61 @@
1
+ import { and, eq, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, or, sql, } from "drizzle-orm";
2
+ import { isCompositeFilter, isLeafFilter, OPAQUE_FILTER_ERROR, } from "../filter.js";
3
+ export { OPAQUE_FILTER_ERROR } from "../filter.js";
4
+ function escapeLikePattern(value) {
5
+ return String(value).replace(/([%_!])/g, "!$1");
6
+ }
7
+ function likeWithEscape(column, pattern) {
8
+ return sql `${column} like ${pattern} escape '!'`;
9
+ }
10
+ function ilikeWithEscape(column, pattern) {
11
+ return sql `${column} ilike ${pattern} escape '!'`;
12
+ }
13
+ function compileLeaf(table, filter, opts) {
14
+ const columns = getTableColumns(table);
15
+ const column = columns[filter.field];
16
+ if (!column) {
17
+ throw new Error(`[sveltekit-admin] unknown field '${filter.field}' on Drizzle table`);
18
+ }
19
+ switch (filter.op) {
20
+ case "eq":
21
+ return eq(column, filter.value);
22
+ case "contains": {
23
+ const pattern = `%${escapeLikePattern(filter.value)}%`;
24
+ if (!opts.caseInsensitiveSearch)
25
+ return likeWithEscape(column, pattern);
26
+ if (opts.dialect === "postgresql")
27
+ return ilikeWithEscape(column, pattern);
28
+ return sql `lower(${column}) like lower(${pattern}) escape '!'`;
29
+ }
30
+ case "containsExact":
31
+ return likeWithEscape(column, `%${escapeLikePattern(filter.value)}%`);
32
+ case "startsWith":
33
+ return likeWithEscape(column, `${escapeLikePattern(filter.value)}%`);
34
+ case "gte":
35
+ return gte(column, filter.value);
36
+ case "lte":
37
+ return lte(column, filter.value);
38
+ case "lt":
39
+ return lt(column, filter.value);
40
+ case "in":
41
+ return inArray(column, filter.value);
42
+ case "isNull":
43
+ return isNull(column);
44
+ case "isNotNull":
45
+ return isNotNull(column);
46
+ }
47
+ }
48
+ function compile(table, node, opts) {
49
+ if (isCompositeFilter(node)) {
50
+ const clauses = node.clauses.map((clause) => compile(table, clause, opts));
51
+ return node.op === "and" ? and(...clauses) : or(...clauses);
52
+ }
53
+ if (isLeafFilter(node))
54
+ return compileLeaf(table, node, opts);
55
+ throw new Error(OPAQUE_FILTER_ERROR);
56
+ }
57
+ export function compileFilterToDrizzle(table, filter, opts) {
58
+ if (filter === undefined)
59
+ return undefined;
60
+ return compile(table, filter, opts);
61
+ }
@@ -0,0 +1,22 @@
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";
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 };
11
+ export type { DrizzleDialect };
12
+ export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult, } from "../../plugin.js";
13
+ export declare function resolveCaseInsensitiveSearch(dialect: DrizzleDialect, searchMode?: "auto" | "insensitive" | "default"): boolean;
14
+ export declare function createDrizzleAdapter(opts: {
15
+ db: any;
16
+ schema: Record<string, unknown>;
17
+ dialect?: DrizzleDialect;
18
+ searchMode?: "auto" | "insensitive" | "default";
19
+ }): {
20
+ introspector: SchemaIntrospector;
21
+ data: DataAdapter;
22
+ };
@@ -0,0 +1,27 @@
1
+ import { defaultAdminCheck } from "../../auth.js";
2
+ import { createAdminHandler } from "../../handler.js";
3
+ import { createDrizzleDataAdapter } from "./dataAdapter.js";
4
+ import { inspectDrizzleSchema } from "./inspect.js";
5
+ import { createDrizzleIntrospector } from "./introspector.js";
6
+ export { createAdminHandler };
7
+ export { defaultAdminCheck };
8
+ export function resolveCaseInsensitiveSearch(dialect, searchMode = "auto") {
9
+ if (searchMode === "insensitive")
10
+ return true;
11
+ if (searchMode === "default")
12
+ return false;
13
+ return dialect === "postgresql";
14
+ }
15
+ export function createDrizzleAdapter(opts) {
16
+ const inspected = inspectDrizzleSchema(opts.schema, opts.dialect);
17
+ const caseInsensitiveSearch = resolveCaseInsensitiveSearch(inspected.dialect, opts.searchMode);
18
+ return {
19
+ introspector: createDrizzleIntrospector(inspected.schema),
20
+ data: createDrizzleDataAdapter(opts.db, {
21
+ tables: inspected.tables,
22
+ m2m: inspected.m2m,
23
+ dialect: inspected.dialect,
24
+ caseInsensitiveSearch,
25
+ }),
26
+ };
27
+ }
@@ -0,0 +1,23 @@
1
+ import { Table } from 'drizzle-orm';
2
+ import type { Column } from 'drizzle-orm';
3
+ import type { Schema } from '../../types/schema.js';
4
+ export type DrizzleDialect = 'postgresql' | 'mysql' | 'sqlite';
5
+ export interface M2mLink {
6
+ pivot: Table;
7
+ selfColumn: Column;
8
+ otherColumn: Column;
9
+ selfKey: string;
10
+ otherKey: string;
11
+ targetTsName: string;
12
+ }
13
+ export interface InspectedDrizzleSchema {
14
+ schema: Schema;
15
+ tables: Record<string, Table>;
16
+ m2m: Map<string, M2mLink>;
17
+ dialect: DrizzleDialect;
18
+ }
19
+ export declare function mapColumnType(column: Column, enumTypeName?: string): {
20
+ type: string;
21
+ isEnum: boolean;
22
+ };
23
+ export declare function inspectDrizzleSchema(schemaObj: Record<string, unknown>, dialectOverride?: DrizzleDialect): InspectedDrizzleSchema;