sveltekit-admin 0.5.3 â 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -1
- package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
- package/dist/server/adapters/drizzle/dataAdapter.js +328 -0
- package/dist/server/adapters/drizzle/filterCompiler.d.ts +8 -0
- package/dist/server/adapters/drizzle/filterCompiler.js +61 -0
- package/dist/server/adapters/drizzle/index.d.ts +22 -0
- package/dist/server/adapters/drizzle/index.js +27 -0
- package/dist/server/adapters/drizzle/inspect.d.ts +23 -0
- package/dist/server/adapters/drizzle/inspect.js +220 -0
- package/dist/server/adapters/drizzle/introspector.d.ts +3 -0
- package/dist/server/adapters/drizzle/introspector.js +3 -0
- package/dist/server/adapters/filter.d.ts +12 -0
- package/dist/server/adapters/filter.js +72 -0
- package/dist/server/adapters/prisma/dataAdapter.d.ts +13 -0
- package/dist/server/adapters/prisma/dataAdapter.js +138 -0
- package/dist/server/adapters/prisma/filterCompiler.d.ts +9 -0
- package/dist/server/adapters/prisma/filterCompiler.js +42 -0
- package/dist/server/adapters/prisma/handler.d.ts +14 -0
- package/dist/server/adapters/prisma/handler.js +37 -0
- package/dist/server/adapters/prisma/index.d.ts +28 -0
- package/dist/server/adapters/prisma/index.js +32 -0
- package/dist/server/adapters/prisma/introspector.d.ts +4 -0
- package/dist/server/adapters/prisma/introspector.js +9 -0
- package/dist/server/adapters/retry.d.ts +27 -0
- package/dist/server/adapters/retry.js +53 -0
- package/dist/server/adapters/types.d.ts +86 -0
- package/dist/server/adapters/types.js +1 -0
- package/dist/server/audit.d.ts +65 -0
- package/dist/server/audit.js +106 -0
- package/dist/server/csrf.d.ts +33 -0
- package/dist/server/csrf.js +55 -0
- package/dist/server/data.d.ts +0 -8
- package/dist/server/data.js +0 -41
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +63 -16
- package/dist/server/handler.js +212 -535
- package/dist/server/introspection/parser.d.ts +4 -40
- package/dist/server/introspection/relations.d.ts +1 -1
- package/dist/server/introspection/relations.js +1 -1
- package/dist/server/mutations.d.ts +9 -0
- package/dist/server/mutations.js +295 -0
- package/dist/server/plugin.d.ts +47 -0
- package/dist/server/plugin.js +1 -0
- package/dist/server/pluginAccess.d.ts +7 -0
- package/dist/server/pluginAccess.js +79 -0
- package/dist/server/pluginRegistry.d.ts +12 -0
- package/dist/server/pluginRegistry.js +72 -0
- package/dist/server/query/listQuery.d.ts +6 -12
- package/dist/server/query/listQuery.js +31 -53
- package/dist/server/relationLoaders.d.ts +42 -0
- package/dist/server/relationLoaders.js +188 -0
- package/dist/server/router.d.ts +10 -0
- package/dist/server/router.js +42 -19
- package/dist/server/runtime.d.ts +46 -0
- package/dist/server/runtime.js +210 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/types/schema.d.ts +40 -0
- package/dist/server/types/schema.js +8 -0
- package/dist/server/views/Form.svelte +27 -3
- package/dist/server/views/Form.svelte.d.ts +2 -1
- package/dist/server/views/Layout.svelte +27 -2
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +28 -3
- package/dist/server/views/List.svelte.d.ts +2 -2
- package/dist/server/views/RelationCheckboxes.svelte +1 -1
- package/dist/server/views/types.d.ts +8 -0
- package/package.json +40 -17
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|

|
|
6
6
|

|
|
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;
|