@truto/ginger 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +597 -0
- package/dist/adapters/bun-sqlite.d.ts +37 -0
- package/dist/adapters/bun-sqlite.d.ts.map +1 -0
- package/dist/adapters/bun-sqlite.js +136 -0
- package/dist/adapters/bun-sqlite.js.map +1 -0
- package/dist/adapters/durable-object.d.ts +40 -0
- package/dist/adapters/durable-object.d.ts.map +1 -0
- package/dist/adapters/durable-object.js +142 -0
- package/dist/adapters/durable-object.js.map +1 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +3 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/crypto.d.ts +40 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +148 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +64 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +90 -0
- package/dist/errors.js.map +1 -0
- package/dist/example.d.ts +119 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +297 -0
- package/dist/example.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +31 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +173 -0
- package/dist/pagination.js.map +1 -0
- package/dist/service.d.ts +81 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +615 -0
- package/dist/service.js.map +1 -0
- package/dist/sql-builder.d.ts +48 -0
- package/dist/sql-builder.d.ts.map +1 -0
- package/dist/sql-builder.js +230 -0
- package/dist/sql-builder.js.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
# Ginger
|
|
2
|
+
|
|
3
|
+
A type-safe SQLite data access layer that works with **Cloudflare D1**, **Bun SQLite**, and **Durable Object SqlStorage**. Comes with cursor-based pagination, declarative joins, AES-256-GCM field encryption, and a Feathers.js-inspired hook system.
|
|
4
|
+
|
|
5
|
+
Built with TypeScript and [Zod v4](https://zod.dev/) for complete type safety. All dynamic SQL is generated via [@truto/sqlite-builder](https://github.com/trutohq/truto-sqlite-builder) — no raw string concatenation, ever.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add ginger
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add zod @truto/sqlite-builder
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Fully type-safe** — Zod schemas drive runtime validation and static types
|
|
22
|
+
- **Cursor pagination** — opaque base64 cursors with `next` / `prev` support
|
|
23
|
+
- **Declarative joins** — `one` and `many` joins with conditional `include`
|
|
24
|
+
- **Field encryption** — AES-256-GCM via Web Crypto, stored as `kid:iv:cipher`
|
|
25
|
+
- **Hook system** — `before` / `after` / `error` hooks per method, inspired by Feathers.js
|
|
26
|
+
- **Dependency injection** — pass other services via `deps` for cross-service logic
|
|
27
|
+
- **SQL injection protection** — every query is parameterised through `@truto/sqlite-builder`
|
|
28
|
+
- **Custom error hierarchy** — `NotFoundError`, `ValidationError`, `AuthError`, `EncryptionError`, etc.
|
|
29
|
+
|
|
30
|
+
## Quick example
|
|
31
|
+
|
|
32
|
+
A complete `users` service with an encrypted `apiKey`, a join to `teams`, a custom `withMembership` method, and a hook that enforces tenant filtering via `auth.user`.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import {
|
|
36
|
+
createService,
|
|
37
|
+
Service,
|
|
38
|
+
z,
|
|
39
|
+
type AuthContext,
|
|
40
|
+
type Database,
|
|
41
|
+
type JoinDef,
|
|
42
|
+
type SecretFieldDef,
|
|
43
|
+
} from 'ginger'
|
|
44
|
+
|
|
45
|
+
// ── Schemas ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const UserRow = z.object({
|
|
48
|
+
id: z.number(),
|
|
49
|
+
name: z.string(),
|
|
50
|
+
email: z.string(),
|
|
51
|
+
tenant_id: z.string(),
|
|
52
|
+
created_at: z.string(),
|
|
53
|
+
updated_at: z.string().nullable(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const CreateUser = z.object({
|
|
57
|
+
name: z.string().min(1).max(255),
|
|
58
|
+
email: z.string().email(),
|
|
59
|
+
apiKey: z.string().min(32),
|
|
60
|
+
tenant_id: z.string(),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const UpdateUser = z.object({
|
|
64
|
+
name: z.string().min(1).max(255).optional(),
|
|
65
|
+
email: z.string().email().optional(),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const TeamRow = z.object({
|
|
69
|
+
id: z.number(),
|
|
70
|
+
name: z.string(),
|
|
71
|
+
description: z.string(),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ── Joins ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const userJoins = {
|
|
77
|
+
teams: {
|
|
78
|
+
kind: 'many',
|
|
79
|
+
localPk: 'id',
|
|
80
|
+
through: {
|
|
81
|
+
table: 'user_teams',
|
|
82
|
+
from: 'user_id',
|
|
83
|
+
to: 'team_id',
|
|
84
|
+
},
|
|
85
|
+
remote: {
|
|
86
|
+
table: 'teams',
|
|
87
|
+
pk: 'id',
|
|
88
|
+
select: ['id', 'name', 'description'],
|
|
89
|
+
},
|
|
90
|
+
schema: TeamRow,
|
|
91
|
+
},
|
|
92
|
+
} satisfies Record<string, JoinDef>
|
|
93
|
+
|
|
94
|
+
// ── Secrets ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const userSecrets = [
|
|
97
|
+
{
|
|
98
|
+
logicalName: 'apiKey',
|
|
99
|
+
columnName: 'api_key_encrypted',
|
|
100
|
+
keyId: 'user-secrets',
|
|
101
|
+
},
|
|
102
|
+
] as const satisfies readonly SecretFieldDef[]
|
|
103
|
+
|
|
104
|
+
// ── Service ──────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
class UsersService extends Service<
|
|
107
|
+
typeof UserRow,
|
|
108
|
+
typeof CreateUser,
|
|
109
|
+
typeof UpdateUser,
|
|
110
|
+
typeof userJoins,
|
|
111
|
+
typeof userSecrets
|
|
112
|
+
> {
|
|
113
|
+
/** Fetch a user together with their team memberships */
|
|
114
|
+
async withMembership(id: number, auth: AuthContext) {
|
|
115
|
+
return this.get(id, {
|
|
116
|
+
auth,
|
|
117
|
+
include: { teams: true },
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Factory ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function createUsersService(
|
|
125
|
+
db: Database,
|
|
126
|
+
encryptionKeys: Record<string, string>,
|
|
127
|
+
) {
|
|
128
|
+
return new UsersService({
|
|
129
|
+
table: 'users',
|
|
130
|
+
db: db as any,
|
|
131
|
+
rowSchema: UserRow,
|
|
132
|
+
createSchema: CreateUser,
|
|
133
|
+
updateSchema: UpdateUser,
|
|
134
|
+
joins: userJoins,
|
|
135
|
+
secrets: userSecrets,
|
|
136
|
+
encryptionKeys,
|
|
137
|
+
hooks: {
|
|
138
|
+
list: {
|
|
139
|
+
before: async (ctx: any) => {
|
|
140
|
+
if (!ctx.auth.user?.tenantId) throw new Error('Missing tenant')
|
|
141
|
+
ctx.params.where = {
|
|
142
|
+
...ctx.params.where,
|
|
143
|
+
tenant_id: ctx.auth.user.tenantId,
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
create: {
|
|
148
|
+
before: async (ctx: any) => {
|
|
149
|
+
if (!ctx.auth.user?.tenantId) throw new Error('Missing tenant')
|
|
150
|
+
ctx.data.tenant_id = ctx.auth.user.tenantId
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Worker entry point ───────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export default {
|
|
160
|
+
async fetch(request: Request, env: any): Promise<Response> {
|
|
161
|
+
const usersService = createUsersService(env.DB, {
|
|
162
|
+
default: env.ENCRYPTION_KEY,
|
|
163
|
+
'user-secrets': env.ENCRYPTION_KEY,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const auth = {
|
|
167
|
+
user: { id: 'usr_1', tenantId: 'tnt_1', roles: ['admin'] },
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Create (apiKey is encrypted transparently)
|
|
171
|
+
const user = await usersService.create(
|
|
172
|
+
{
|
|
173
|
+
name: 'Jane Doe',
|
|
174
|
+
email: 'jane@example.com',
|
|
175
|
+
apiKey: 'sk_ex_abcdef1234567890abcdef1234567890',
|
|
176
|
+
tenant_id: 'tnt_1',
|
|
177
|
+
},
|
|
178
|
+
{ auth },
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// List with pagination + joins
|
|
182
|
+
const page = await usersService.list({
|
|
183
|
+
auth,
|
|
184
|
+
limit: 20,
|
|
185
|
+
include: { teams: true },
|
|
186
|
+
orderBy: [{ column: 'created_at', direction: 'desc' }],
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Get with decrypted secrets
|
|
190
|
+
const full = await usersService.get(user.id, {
|
|
191
|
+
auth,
|
|
192
|
+
includeSecrets: true,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Custom method
|
|
196
|
+
const withTeams = await usersService.withMembership(user.id, auth)
|
|
197
|
+
|
|
198
|
+
return Response.json({ user, page, full, withTeams })
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Core concepts
|
|
204
|
+
|
|
205
|
+
### Database adapters
|
|
206
|
+
|
|
207
|
+
Ginger works with any SQLite database that satisfies the `Database` interface. Three adapters are provided out of the box:
|
|
208
|
+
|
|
209
|
+
**Cloudflare D1** — pass the binding directly, no adapter needed:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { createService } from 'ginger'
|
|
213
|
+
|
|
214
|
+
const service = createService({
|
|
215
|
+
table: 'users',
|
|
216
|
+
db: env.DB, // D1 binding satisfies Database natively
|
|
217
|
+
// ...
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Bun SQLite** — wrap with `fromBunSqlite`:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import { Database } from 'bun:sqlite'
|
|
225
|
+
import { createService, fromBunSqlite } from 'ginger'
|
|
226
|
+
|
|
227
|
+
const bunDb = new Database('myapp.sqlite')
|
|
228
|
+
const service = createService({
|
|
229
|
+
table: 'users',
|
|
230
|
+
db: fromBunSqlite(bunDb),
|
|
231
|
+
// ...
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Durable Object SqlStorage** — wrap with `fromDurableObjectStorage`:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
239
|
+
import { createService, fromDurableObjectStorage } from 'ginger'
|
|
240
|
+
|
|
241
|
+
export class MyDO extends DurableObject {
|
|
242
|
+
service = createService({
|
|
243
|
+
table: 'users',
|
|
244
|
+
db: fromDurableObjectStorage(this.ctx.storage.sql),
|
|
245
|
+
// ...
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Service configuration
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { createService, z } from 'ginger'
|
|
254
|
+
|
|
255
|
+
const service = createService({
|
|
256
|
+
table: 'users',
|
|
257
|
+
db, // Database instance (D1, fromBunSqlite, or fromDurableObjectStorage)
|
|
258
|
+
rowSchema: UserRow, // canonical decoded row
|
|
259
|
+
createSchema: CreateUser, // POST body schema
|
|
260
|
+
updateSchema: UpdateUser, // PATCH body schema (partial)
|
|
261
|
+
joins: userJoins, // declarative join map
|
|
262
|
+
secrets: userSecrets, // secret field definitions
|
|
263
|
+
hooks: {
|
|
264
|
+
/* ... */
|
|
265
|
+
}, // before/after/error hooks
|
|
266
|
+
deps: { teams: teamsService }, // other services
|
|
267
|
+
primaryKey: 'id', // default "id"
|
|
268
|
+
defaultOrderBy: { column: 'created_at', direction: 'desc' },
|
|
269
|
+
keyProvider: customProvider, // or pass encryptionKeys: { ... }
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### CRUD methods
|
|
274
|
+
|
|
275
|
+
Every service gets these methods out of the box:
|
|
276
|
+
|
|
277
|
+
| Method | Description |
|
|
278
|
+
| ----------------------------- | ---------------------------------------------------------------- |
|
|
279
|
+
| `list(params)` | Cursor-based paginated list with filtering, ordering, and joins |
|
|
280
|
+
| `get(id, opts)` | Single record by ID with optional `include` and `includeSecrets` |
|
|
281
|
+
| `create(data, opts)` | Validates with `createSchema`, returns decoded row |
|
|
282
|
+
| `update(id, data, opts)` | Partial update, merges with existing row |
|
|
283
|
+
| `delete(id, opts)` | Hard delete |
|
|
284
|
+
| `count(params)` | Count rows matching a typed `where` clause |
|
|
285
|
+
| `query(sql, opts, ...params)` | Low-level escape hatch returning decoded rows |
|
|
286
|
+
|
|
287
|
+
All methods accept an `auth` object:
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
interface AuthContext {
|
|
291
|
+
user?: {
|
|
292
|
+
id: string
|
|
293
|
+
roles: string[]
|
|
294
|
+
[k: string]: unknown
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Pagination
|
|
300
|
+
|
|
301
|
+
Opaque cursor tokens (base64-encoded JSON) with `next` / `prev` support:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
const page1 = await service.list({
|
|
305
|
+
auth,
|
|
306
|
+
limit: 20,
|
|
307
|
+
orderBy: [{ column: 'created_at', direction: 'desc' }],
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// page1.result — array of rows
|
|
311
|
+
// page1.nextCursor — pass to next call for the next page
|
|
312
|
+
// page1.prevCursor — pass to next call for the previous page
|
|
313
|
+
|
|
314
|
+
const page2 = await service.list({
|
|
315
|
+
auth,
|
|
316
|
+
cursor: page1.nextCursor,
|
|
317
|
+
limit: 20,
|
|
318
|
+
})
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Joins
|
|
322
|
+
|
|
323
|
+
Define type-safe joins with conditional inclusion. The return type of `get` / `list` changes based on which joins are included:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
const joins = {
|
|
327
|
+
profile: {
|
|
328
|
+
kind: 'one' as const,
|
|
329
|
+
localPk: 'id',
|
|
330
|
+
remote: {
|
|
331
|
+
table: 'profiles',
|
|
332
|
+
pk: 'user_id',
|
|
333
|
+
select: ['bio', 'avatar'],
|
|
334
|
+
},
|
|
335
|
+
schema: ProfileSchema,
|
|
336
|
+
},
|
|
337
|
+
teams: {
|
|
338
|
+
kind: 'many' as const,
|
|
339
|
+
localPk: 'id',
|
|
340
|
+
through: {
|
|
341
|
+
table: 'user_teams',
|
|
342
|
+
from: 'user_id',
|
|
343
|
+
to: 'team_id',
|
|
344
|
+
},
|
|
345
|
+
remote: {
|
|
346
|
+
table: 'teams',
|
|
347
|
+
pk: 'id',
|
|
348
|
+
select: ['id', 'name'],
|
|
349
|
+
},
|
|
350
|
+
where: 'teams.active = 1',
|
|
351
|
+
schema: TeamSchema,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const user = await service.get(id, {
|
|
356
|
+
auth,
|
|
357
|
+
include: { profile: true, teams: true },
|
|
358
|
+
})
|
|
359
|
+
// user.profile → ProfileRow | null
|
|
360
|
+
// user.teams → TeamRow[]
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Field encryption
|
|
364
|
+
|
|
365
|
+
Sensitive fields are encrypted with **AES-256-GCM** via Web Crypto and stored as `kid:iv:cipher` (base64 segments):
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
const secrets = [
|
|
369
|
+
{
|
|
370
|
+
logicalName: 'apiKey', // field in your schema
|
|
371
|
+
columnName: 'api_key_enc', // column in the DB
|
|
372
|
+
keyId: 'api-keys', // key identifier
|
|
373
|
+
},
|
|
374
|
+
] as const
|
|
375
|
+
|
|
376
|
+
// Provide keys directly
|
|
377
|
+
const service = createService({
|
|
378
|
+
// ...
|
|
379
|
+
secrets,
|
|
380
|
+
encryptionKeys: {
|
|
381
|
+
default: env.ENCRYPTION_KEY,
|
|
382
|
+
'api-keys': env.API_KEY_ENCRYPTION_KEY,
|
|
383
|
+
},
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// Or provide a custom KeyProvider
|
|
387
|
+
const service = createService({
|
|
388
|
+
// ...
|
|
389
|
+
secrets,
|
|
390
|
+
keyProvider: {
|
|
391
|
+
async getKey(keyId: string): Promise<CryptoKey> {
|
|
392
|
+
// your custom key retrieval logic
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Generate a key:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
import { generateSecretKey } from 'ginger'
|
|
402
|
+
|
|
403
|
+
const key = await generateSecretKey()
|
|
404
|
+
// → base64-encoded 256-bit key
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Encryption is injected automatically via hooks:
|
|
408
|
+
|
|
409
|
+
- `before.create` / `before.update` — encrypt logical fields → ciphertext column
|
|
410
|
+
- `after.get` / `after.list` (when `includeSecrets: true`) — decrypt back
|
|
411
|
+
|
|
412
|
+
### Hooks
|
|
413
|
+
|
|
414
|
+
Feathers.js-inspired hooks with `before` / `after` / `error` phases:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
const service = createService({
|
|
418
|
+
// ...
|
|
419
|
+
hooks: {
|
|
420
|
+
list: {
|
|
421
|
+
before: [authHook, tenantFilterHook],
|
|
422
|
+
after: [auditLogHook],
|
|
423
|
+
error: [errorReportingHook],
|
|
424
|
+
},
|
|
425
|
+
create: {
|
|
426
|
+
before: async (ctx) => {
|
|
427
|
+
ctx.data.createdBy = ctx.auth.user?.id
|
|
428
|
+
},
|
|
429
|
+
after: async (ctx) => {
|
|
430
|
+
await sendWelcomeEmail(ctx.result.email)
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
})
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Hooks receive a context object:
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
interface BaseCtx {
|
|
441
|
+
auth: AuthContext
|
|
442
|
+
db: Database
|
|
443
|
+
deps: ServiceDeps
|
|
444
|
+
method: MethodName
|
|
445
|
+
params?: unknown
|
|
446
|
+
data?: unknown
|
|
447
|
+
result?: unknown
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Hooks run sequentially in registration order. If a `before` or `after` hook throws, control jumps to the `error` chain.
|
|
452
|
+
|
|
453
|
+
### Custom methods
|
|
454
|
+
|
|
455
|
+
Extend `Service` to add arbitrary async methods that can leverage all built-in functionality:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
class UsersService extends Service</* ... */> {
|
|
459
|
+
async findByEmail(email: string, auth: AuthContext) {
|
|
460
|
+
const results = await this.query(
|
|
461
|
+
'SELECT * FROM users WHERE email = ?',
|
|
462
|
+
{ auth },
|
|
463
|
+
email,
|
|
464
|
+
)
|
|
465
|
+
return results[0] ?? null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async deactivate(id: number, auth: AuthContext) {
|
|
469
|
+
return this.update(id, { active: false }, { auth })
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Dependency injection
|
|
475
|
+
|
|
476
|
+
Pass other services via `deps` — they're available on `this.deps` and in every hook context:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
const teamsService = createService({
|
|
480
|
+
/* ... */
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
const usersService = createService({
|
|
484
|
+
// ...
|
|
485
|
+
deps: { teams: teamsService },
|
|
486
|
+
hooks: {
|
|
487
|
+
delete: {
|
|
488
|
+
after: async (ctx) => {
|
|
489
|
+
// Clean up team memberships when a user is deleted
|
|
490
|
+
await ctx.deps.teams.query(
|
|
491
|
+
'DELETE FROM user_teams WHERE user_id = ?',
|
|
492
|
+
{ auth: ctx.auth },
|
|
493
|
+
ctx.params.id,
|
|
494
|
+
)
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
})
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### Error handling
|
|
502
|
+
|
|
503
|
+
All errors extend `ServiceError` with structured `code` and `statusCode`:
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
import {
|
|
507
|
+
ServiceError,
|
|
508
|
+
NotFoundError,
|
|
509
|
+
ValidationError,
|
|
510
|
+
AuthError,
|
|
511
|
+
DatabaseError,
|
|
512
|
+
EncryptionError,
|
|
513
|
+
HookError,
|
|
514
|
+
CursorError,
|
|
515
|
+
} from 'ginger'
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
await service.get(id, { auth })
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (error instanceof NotFoundError) {
|
|
521
|
+
return new Response('Not found', { status: 404 })
|
|
522
|
+
}
|
|
523
|
+
if (error instanceof ValidationError) {
|
|
524
|
+
return new Response(error.message, { status: 400 })
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
| Error class | Code | Status |
|
|
530
|
+
| ----------------- | ------------------ | ------ |
|
|
531
|
+
| `NotFoundError` | `NOT_FOUND` | 404 |
|
|
532
|
+
| `ValidationError` | `VALIDATION_ERROR` | 400 |
|
|
533
|
+
| `AuthError` | `AUTH_ERROR` | 403 |
|
|
534
|
+
| `DatabaseError` | `DATABASE_ERROR` | 500 |
|
|
535
|
+
| `EncryptionError` | `ENCRYPTION_ERROR` | 500 |
|
|
536
|
+
| `HookError` | `HOOK_ERROR` | 500 |
|
|
537
|
+
| `CursorError` | `CURSOR_ERROR` | 400 |
|
|
538
|
+
|
|
539
|
+
## SQL schema example
|
|
540
|
+
|
|
541
|
+
```sql
|
|
542
|
+
CREATE TABLE users (
|
|
543
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
544
|
+
name TEXT NOT NULL,
|
|
545
|
+
email TEXT NOT NULL UNIQUE,
|
|
546
|
+
api_key_encrypted TEXT,
|
|
547
|
+
tenant_id TEXT NOT NULL,
|
|
548
|
+
created_at TEXT NOT NULL,
|
|
549
|
+
updated_at TEXT
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
CREATE TABLE teams (
|
|
553
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
554
|
+
name TEXT NOT NULL,
|
|
555
|
+
description TEXT,
|
|
556
|
+
active INTEGER DEFAULT 1
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
CREATE TABLE user_teams (
|
|
560
|
+
user_id INTEGER NOT NULL,
|
|
561
|
+
team_id INTEGER NOT NULL,
|
|
562
|
+
PRIMARY KEY (user_id, team_id),
|
|
563
|
+
FOREIGN KEY (user_id) REFERENCES users(id),
|
|
564
|
+
FOREIGN KEY (team_id) REFERENCES teams(id)
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
CREATE TABLE profiles (
|
|
568
|
+
user_id INTEGER PRIMARY KEY,
|
|
569
|
+
bio TEXT,
|
|
570
|
+
avatar TEXT,
|
|
571
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
572
|
+
);
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
## Requirements
|
|
576
|
+
|
|
577
|
+
- Any runtime with Web Crypto (Cloudflare Workers, Bun, Node 20+)
|
|
578
|
+
- A supported SQLite backend: Cloudflare D1, `bun:sqlite`, or Durable Object `SqlStorage`
|
|
579
|
+
- TypeScript 5.0+
|
|
580
|
+
- Zod 3.25+ (v4)
|
|
581
|
+
- @truto/sqlite-builder 1.0+
|
|
582
|
+
|
|
583
|
+
## Development
|
|
584
|
+
|
|
585
|
+
```bash
|
|
586
|
+
bun install # Install dependencies
|
|
587
|
+
bun test # Run tests
|
|
588
|
+
bun run dev # Run tests in watch mode
|
|
589
|
+
bun run build # Build the library
|
|
590
|
+
bun run typecheck # TypeScript type checking
|
|
591
|
+
bun run lint # ESLint
|
|
592
|
+
bun run format # Prettier
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## License
|
|
596
|
+
|
|
597
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Database } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Structural interface matching Bun's `bun:sqlite` Statement.
|
|
4
|
+
* No runtime import of `bun:sqlite` is needed — TypeScript's structural
|
|
5
|
+
* typing lets the real Bun `Database` satisfy this automatically.
|
|
6
|
+
*/
|
|
7
|
+
export interface BunSqliteStatement {
|
|
8
|
+
get(...params: unknown[]): unknown;
|
|
9
|
+
all(...params: unknown[]): unknown[];
|
|
10
|
+
run(...params: unknown[]): {
|
|
11
|
+
changes: number;
|
|
12
|
+
lastInsertRowid: number | bigint;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Structural interface matching Bun's `bun:sqlite` Database.
|
|
17
|
+
*/
|
|
18
|
+
export interface BunSqliteDatabase {
|
|
19
|
+
prepare(query: string): BunSqliteStatement;
|
|
20
|
+
exec(query: string): void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a Bun `Database` (from `bun:sqlite`) so it satisfies the
|
|
24
|
+
* generic {@link Database} interface used by Ginger services.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { Database } from 'bun:sqlite'
|
|
29
|
+
* import { createService, fromBunSqlite } from 'ginger'
|
|
30
|
+
*
|
|
31
|
+
* const bunDb = new Database(':memory:')
|
|
32
|
+
* const db = fromBunSqlite(bunDb)
|
|
33
|
+
* const service = createService({ db, ... })
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function fromBunSqlite(bunDb: BunSqliteDatabase): Database;
|
|
37
|
+
//# sourceMappingURL=bun-sqlite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bun-sqlite.d.ts","sourceRoot":"","sources":["../../src/adapters/bun-sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EAIT,MAAM,aAAa,CAAA;AAEpB;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;IAClC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAAA;IACpC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG;QACzB,OAAO,EAAE,MAAM,CAAA;QACf,eAAe,EAAE,MAAM,GAAG,MAAM,CAAA;KACjC,CAAA;CACF;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,kBAAkB,CAAA;IAC1C,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,iBAAiB,GAAG,QAAQ,CA4HhE"}
|