unconventional 2.0.0 → 2.1.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/README.md +799 -15
- package/dist/core/abstractbase.controller.js +1 -1
- package/dist/core/base.model.d.ts +1 -1
- package/dist/core/base.model.js +5 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,15 +1,799 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
# Unconventional
|
|
2
|
+
|
|
3
|
+
> A complete backend framework built on Cloudflare Workers/Pages and Hono
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/unconventional)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**Unconventional** is a powerful, production-ready backend framework designed for Cloudflare Workers and Pages. It provides a complete set of tools for building RESTful APIs with minimal boilerplate, including automatic CRUD operations, database management, caching, security features, and more.
|
|
9
|
+
|
|
10
|
+
## ✨ Features
|
|
11
|
+
|
|
12
|
+
- 🚀 **Complete CRUD Operations** - Automatic REST endpoints for all your models
|
|
13
|
+
- 🗄️ **PostgreSQL Integration** - Built-in support via `unconventional-pg-queries`
|
|
14
|
+
- 🎨 **Decorator-Based Models** - Define models with `@prop`, `@index`, and `@timestamp` decorators
|
|
15
|
+
- ⏰ **Automatic Timestamps** - Built-in `createdAt` and `updatedAt` management
|
|
16
|
+
- 💾 **Cloudflare KV Caching** - Intelligent caching layer with automatic invalidation
|
|
17
|
+
- 🔒 **Security First** - IDOR protection and private field masking out of the box
|
|
18
|
+
- 🔗 **Relationship Support** - HasOne, HasMany, BelongsTo, and ManyToMany relations
|
|
19
|
+
- 🛡️ **Middleware Support** - Extensible middleware system for authentication and more
|
|
20
|
+
- 📊 **Advanced Querying** - Pagination, filtering, sorting, and query expansion
|
|
21
|
+
- 🆔 **Flexible IDs** - Support for both numeric IDs and UUIDs
|
|
22
|
+
- 🌐 **CORS Ready** - Pre-configured CORS with sensible defaults
|
|
23
|
+
- ⚡ **Edge Optimized** - Built for Cloudflare's edge network
|
|
24
|
+
|
|
25
|
+
## 📦 Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install unconventional
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Peer Dependencies
|
|
32
|
+
|
|
33
|
+
Unconventional requires the following peer dependencies:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install hono@^4 unconventional-pg-queries@^1.6.0
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Note:** TypeScript is required for this package.
|
|
40
|
+
|
|
41
|
+
## 🚀 Quick Start
|
|
42
|
+
|
|
43
|
+
### 1. Define a Model
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { BaseModel, prop, timestamp } from 'unconventional';
|
|
47
|
+
|
|
48
|
+
@timestamp()
|
|
49
|
+
export class User extends BaseModel {
|
|
50
|
+
public static collection = 'users';
|
|
51
|
+
public static db: DB;
|
|
52
|
+
|
|
53
|
+
@prop({ required: true, unique: true })
|
|
54
|
+
public email!: string;
|
|
55
|
+
|
|
56
|
+
@prop({ required: true })
|
|
57
|
+
public name!: string;
|
|
58
|
+
|
|
59
|
+
@prop({ private: true })
|
|
60
|
+
public passwordHash?: string;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. Create a Controller
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { AbstractBaseController } from 'unconventional';
|
|
68
|
+
import { User } from './models/user';
|
|
69
|
+
|
|
70
|
+
export class UserController extends AbstractBaseController<typeof User> {
|
|
71
|
+
constructor() {
|
|
72
|
+
super(User);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
protected async maskPrivateFields(req: RequestContext, response: User | User[]): Promise<void> {
|
|
76
|
+
User.maskPrivate(response);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
protected removeSystemFields(req: RequestContext): void {
|
|
80
|
+
delete req.body.id;
|
|
81
|
+
delete req.body.createdAt;
|
|
82
|
+
delete req.body.updatedAt;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected async preventIDOR(req: RequestContext): Promise<void> {
|
|
86
|
+
// Implement your IDOR prevention logic
|
|
87
|
+
// For example, check if user owns the resource
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 3. Set Up Your Server
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { BackendServer, PGFactory } from 'unconventional';
|
|
96
|
+
import { UserController } from './controllers/user.controller';
|
|
97
|
+
|
|
98
|
+
const server = new BackendServer({
|
|
99
|
+
basePath: '/api',
|
|
100
|
+
getDB: PGFactory,
|
|
101
|
+
cors: {
|
|
102
|
+
origin: ['https://example.com'],
|
|
103
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const userController = new UserController();
|
|
108
|
+
|
|
109
|
+
server.app
|
|
110
|
+
.get('/users', userController.getAll())
|
|
111
|
+
.get('/users/:id', userController.get())
|
|
112
|
+
.post('/users', userController.create())
|
|
113
|
+
.put('/users/:id', userController.update())
|
|
114
|
+
.delete('/users/:id', userController.delete());
|
|
115
|
+
|
|
116
|
+
export default server.start();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 4. Configure Cloudflare Workers
|
|
120
|
+
|
|
121
|
+
In your `wrangler.json`:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"name": "my-api",
|
|
126
|
+
"compatibility_date": "2024-01-01",
|
|
127
|
+
"kv_namespaces": [
|
|
128
|
+
{
|
|
129
|
+
"binding": "CACHE",
|
|
130
|
+
"id": "your-kv-namespace-id"
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
"services": [
|
|
134
|
+
{
|
|
135
|
+
"binding": "DB_PROXY",
|
|
136
|
+
"service": "your-postgres-service"
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## 📚 Core Concepts
|
|
143
|
+
|
|
144
|
+
### BaseModel
|
|
145
|
+
|
|
146
|
+
The foundation of Unconventional. `BaseModel` provides static methods for database operations and instance methods for data manipulation.
|
|
147
|
+
|
|
148
|
+
#### Static Methods
|
|
149
|
+
|
|
150
|
+
- `create(data, upsertConfig?)` - Create a new record
|
|
151
|
+
- `createMany(data[], upsertConfig?)` - Create multiple records
|
|
152
|
+
- `findById(id, config?)` - Find by ID or key
|
|
153
|
+
- `findOne(config)` - Find a single record matching criteria
|
|
154
|
+
- `findMany(config?)` - Find multiple records with pagination
|
|
155
|
+
- `update(id, data)` - Update a record
|
|
156
|
+
- `updateMany(data[])` - Update multiple records
|
|
157
|
+
- `delete(id)` - Delete a record
|
|
158
|
+
- `deleteMany(config?)` - Delete multiple records
|
|
159
|
+
- `count()` - Count all records
|
|
160
|
+
- `increment(id, data)` - Increment numeric fields
|
|
161
|
+
- `truncate()` - Delete all records
|
|
162
|
+
|
|
163
|
+
#### Model Configuration
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
export class Product extends BaseModel {
|
|
167
|
+
public static collection = 'products'; // This model's database table
|
|
168
|
+
public static db: DB; // The connection to the database.
|
|
169
|
+
// Recommend setting on BaseModel
|
|
170
|
+
public static idField = 'id'; // The table's id column. Default: id
|
|
171
|
+
public static idType = IdType.UUID; // What column type id is (number or UUID)
|
|
172
|
+
public static keyField = 'slug'; // Optional column used for lookups
|
|
173
|
+
public static ownerField = 'userId'; // For ownership checks
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### BaseService
|
|
178
|
+
|
|
179
|
+
The service layer provides a clean interface for business logic with built-in caching support.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { BaseService } from 'unconventional';
|
|
183
|
+
|
|
184
|
+
const userService = BaseService.for(User);
|
|
185
|
+
|
|
186
|
+
// Create
|
|
187
|
+
const user = await userService.create({ email: 'user@example.com', name: 'John' }, {
|
|
188
|
+
cache: ctx.env.CACHE,
|
|
189
|
+
cacheTTL: 3600,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Get with caching
|
|
193
|
+
const user = await userService.get(userId, {
|
|
194
|
+
cache: ctx.env.CACHE,
|
|
195
|
+
expand: 'profile,posts',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Update
|
|
199
|
+
const updated = await userService.update(userId, { name: 'Jane' }, {
|
|
200
|
+
cache: ctx.env.CACHE,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Delete
|
|
204
|
+
await userService.delete(userId, { cache: ctx.env.CACHE });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### AbstractBaseController
|
|
208
|
+
|
|
209
|
+
Provides automatic CRUD endpoints with hooks for customization.
|
|
210
|
+
|
|
211
|
+
#### Available Methods
|
|
212
|
+
|
|
213
|
+
- `create(options?)` - POST endpoint
|
|
214
|
+
- `createMany(options?)` - POST endpoint for bulk creation
|
|
215
|
+
- `get(options?)` - GET endpoint for single resource
|
|
216
|
+
- `getAll(options?)` - GET endpoint for paginated list
|
|
217
|
+
- `update(options?)` - PUT endpoint
|
|
218
|
+
- `updateMany(options?)` - PUT endpoint for bulk updates
|
|
219
|
+
- `delete(options?)` - DELETE endpoint
|
|
220
|
+
- `deleteMany(options?)` - DELETE endpoint with filter
|
|
221
|
+
|
|
222
|
+
#### Controller Options
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
const controller = new MyController();
|
|
226
|
+
|
|
227
|
+
server.app.get('/users/:id', controller.get({
|
|
228
|
+
before: async (req) => {
|
|
229
|
+
// Pre-processing hook
|
|
230
|
+
},
|
|
231
|
+
after: async (req, response) => {
|
|
232
|
+
// Post-processing hook
|
|
233
|
+
},
|
|
234
|
+
cache: true, // Enable caching for this route
|
|
235
|
+
cacheTTL: 1800, // Custom TTL
|
|
236
|
+
download: async (req, response) => {
|
|
237
|
+
// Return file download instead of JSON
|
|
238
|
+
return { filename: 'export.json', buffer: Buffer.from(JSON.stringify(response)) };
|
|
239
|
+
},
|
|
240
|
+
}));
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### BackendServer
|
|
244
|
+
|
|
245
|
+
The main server class that wraps Hono with Unconventional's defaults.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { BackendServer, PGFactory } from 'unconventional';
|
|
249
|
+
|
|
250
|
+
const server = new BackendServer({
|
|
251
|
+
name: 'My API',
|
|
252
|
+
basePath: '/api/v1',
|
|
253
|
+
getDB: PGFactory, // Database factory function
|
|
254
|
+
cors: { // CORS configuration
|
|
255
|
+
origin: ['https://example.com'],
|
|
256
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
|
257
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
258
|
+
},
|
|
259
|
+
middleware: [ // Middleware to run on all routes
|
|
260
|
+
async (ctx, next) => {
|
|
261
|
+
// Your middleware logic
|
|
262
|
+
return next();
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Access the underlying Hono app
|
|
268
|
+
server.app.get('/health', (ctx) => ctx.json({ status: 'ok' }));
|
|
269
|
+
|
|
270
|
+
export default server.start();
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## 🎨 Decorators
|
|
274
|
+
|
|
275
|
+
### @prop
|
|
276
|
+
|
|
277
|
+
Define model properties with validation and options.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
export class Post extends BaseModel {
|
|
281
|
+
@prop({ required: true })
|
|
282
|
+
public title!: string;
|
|
283
|
+
|
|
284
|
+
@prop({ unique: true })
|
|
285
|
+
public slug!: string;
|
|
286
|
+
|
|
287
|
+
@prop({ default: 'draft' })
|
|
288
|
+
public status!: string;
|
|
289
|
+
|
|
290
|
+
@prop({ default: () => new Date() })
|
|
291
|
+
public publishedAt?: Date;
|
|
292
|
+
|
|
293
|
+
@prop({ private: true })
|
|
294
|
+
public internalNotes?: string;
|
|
295
|
+
|
|
296
|
+
@prop({ system: true })
|
|
297
|
+
public systemField?: string;
|
|
298
|
+
|
|
299
|
+
@prop({
|
|
300
|
+
relation: {
|
|
301
|
+
type: RelationType.BelongsTo,
|
|
302
|
+
model: User,
|
|
303
|
+
from: 'userId',
|
|
304
|
+
to: 'id',
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
public author?: User;
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**Prop Options:**
|
|
312
|
+
- `required` - Field is required
|
|
313
|
+
- `unique` - Field has a unique index in the DB
|
|
314
|
+
- `default` - Default value (can be a function)
|
|
315
|
+
- `private` - Hide from non-privileged users (can be a function)
|
|
316
|
+
- `system` - Prevent editing by non-privileged users (can be a function)
|
|
317
|
+
- `relation` - Define relationships
|
|
318
|
+
- `preFormat` - Transform value before saving
|
|
319
|
+
|
|
320
|
+
### @index
|
|
321
|
+
|
|
322
|
+
Define database indexes for performance.
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
@index({ email: 1 }, { unique: true })
|
|
326
|
+
@index({ createdAt: -1, status: 1 })
|
|
327
|
+
export class User extends BaseModel {
|
|
328
|
+
// ...
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### @timestamp
|
|
333
|
+
|
|
334
|
+
Automatically manage `createdAt` and `updatedAt` fields.
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
@timestamp()
|
|
338
|
+
export class Post extends BaseModel {
|
|
339
|
+
@prop()
|
|
340
|
+
public createdAt?: Date;
|
|
341
|
+
|
|
342
|
+
@prop()
|
|
343
|
+
public updatedAt?: Date;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Custom field names
|
|
347
|
+
@timestamp({ createdField: 'created_at', updatedField: 'updated_at' })
|
|
348
|
+
export class Article extends BaseModel {
|
|
349
|
+
// ...
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## 🔗 Relationships
|
|
354
|
+
|
|
355
|
+
Unconventional supports four types of relationships:
|
|
356
|
+
|
|
357
|
+
### BelongsTo
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
export class Comment extends BaseModel {
|
|
361
|
+
@prop({
|
|
362
|
+
relation: {
|
|
363
|
+
type: RelationType.BelongsTo,
|
|
364
|
+
model: Post,
|
|
365
|
+
from: 'postId',
|
|
366
|
+
to: 'id',
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
public post?: Post;
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### HasOne / HasMany
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
export class User extends BaseModel {
|
|
377
|
+
@prop({
|
|
378
|
+
relation: {
|
|
379
|
+
type: RelationType.HasOne,
|
|
380
|
+
model: Profile,
|
|
381
|
+
from: 'id',
|
|
382
|
+
to: 'userId',
|
|
383
|
+
},
|
|
384
|
+
})
|
|
385
|
+
public profile?: Profile;
|
|
386
|
+
|
|
387
|
+
@prop({
|
|
388
|
+
relation: {
|
|
389
|
+
type: RelationType.HasMany,
|
|
390
|
+
model: Post,
|
|
391
|
+
from: 'id',
|
|
392
|
+
to: 'authorId',
|
|
393
|
+
},
|
|
394
|
+
})
|
|
395
|
+
public posts?: Post[];
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### ManyToMany
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
export class User extends BaseModel {
|
|
403
|
+
@prop({
|
|
404
|
+
relation: {
|
|
405
|
+
type: RelationType.ManyToMany,
|
|
406
|
+
model: Role,
|
|
407
|
+
from: 'id',
|
|
408
|
+
to: 'id',
|
|
409
|
+
through: {
|
|
410
|
+
model: UserRole,
|
|
411
|
+
from: 'userId',
|
|
412
|
+
to: 'roleId',
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
public roles?: Role[];
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Query Expansion
|
|
421
|
+
|
|
422
|
+
Load relationships using the `expand` query parameter:
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
GET /api/users/123?expand=profile,posts,posts.comments
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## 💾 Caching
|
|
429
|
+
|
|
430
|
+
Unconventional provides intelligent caching with Cloudflare KV.
|
|
431
|
+
|
|
432
|
+
### Automatic Caching
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
const controller = new UserController();
|
|
436
|
+
|
|
437
|
+
// Enable caching at controller level
|
|
438
|
+
const controller = new UserController({
|
|
439
|
+
cache: true,
|
|
440
|
+
cacheTTL: 3600, // 1 hour
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Or per-route
|
|
444
|
+
server.app.get('/users/:id', controller.get({ cache: true }));
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Manual Cache Management
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
import { BaseCache } from 'unconventional';
|
|
451
|
+
|
|
452
|
+
// Set a model in cache
|
|
453
|
+
await BaseCache.setModel(cache, user, undefined, 3600);
|
|
454
|
+
|
|
455
|
+
// Get a model from cache
|
|
456
|
+
const cached = await BaseCache.getModel(cache, User, userId);
|
|
457
|
+
|
|
458
|
+
// Clear cache
|
|
459
|
+
await BaseCache.clearModel(cache, user);
|
|
460
|
+
await BaseCache.clearPage(cache, User);
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## 🔒 Security
|
|
464
|
+
|
|
465
|
+
### IDOR Prevention
|
|
466
|
+
|
|
467
|
+
Implement ownership checks in your controller:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
protected async preventIDOR(req: RequestContext): Promise<void> {
|
|
471
|
+
const ownerId = req.get(BaseEnvKey.ownerId);
|
|
472
|
+
const isPrivileged = req.get(BaseEnvKey.isPrivileged);
|
|
473
|
+
|
|
474
|
+
if (isPrivileged) return;
|
|
475
|
+
|
|
476
|
+
// For updates/deletes, check ownership
|
|
477
|
+
if (req.params.id) {
|
|
478
|
+
const resource = await User.findById(req.params.id);
|
|
479
|
+
if (resource?.userId !== ownerId) {
|
|
480
|
+
throw APIError.errForbidden('Access denied');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// For creates, ensure user can't set someone else as owner
|
|
485
|
+
if (req.body.userId && req.body.userId !== ownerId) {
|
|
486
|
+
throw APIError.errForbidden('Cannot create resource for another user');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Private Fields
|
|
492
|
+
|
|
493
|
+
Fields marked with `private: true` are automatically masked:
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
@prop({ private: true })
|
|
497
|
+
public passwordHash?: string;
|
|
498
|
+
|
|
499
|
+
@prop({ private: (model, field) => !model.isPublic })
|
|
500
|
+
public internalData?: string;
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### System Fields
|
|
504
|
+
|
|
505
|
+
Fields marked with `system: true` cannot be edited by non-privileged users:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
@prop({ system: true })
|
|
509
|
+
public systemField?: string;
|
|
510
|
+
|
|
511
|
+
protected removeSystemFields(req: RequestContext): void {
|
|
512
|
+
const isPrivileged = req.get(BaseEnvKey.isPrivileged);
|
|
513
|
+
if (!isPrivileged) {
|
|
514
|
+
delete req.body.systemField;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
## 📊 Querying & Pagination
|
|
520
|
+
|
|
521
|
+
### Filtering
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
// Using query parameters
|
|
525
|
+
GET /api/users?filter=status eq 'active' AND age gt 18
|
|
526
|
+
|
|
527
|
+
// In code
|
|
528
|
+
const users = await User.findMany({
|
|
529
|
+
filter: 'status eq \'active\' AND age gt 18',
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Sorting
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// Query parameter
|
|
537
|
+
GET /api/users?orderBy=createdAt desc
|
|
538
|
+
|
|
539
|
+
// In code
|
|
540
|
+
const users = await User.findMany({
|
|
541
|
+
sort: 'createdAt desc
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Pagination
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
// Cursor-based pagination
|
|
549
|
+
GET /api/users?limit=20&cursor=123
|
|
550
|
+
|
|
551
|
+
// In code
|
|
552
|
+
const users = await User.findMany({
|
|
553
|
+
limit: 20,
|
|
554
|
+
cursor: '123',
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Get all (limit < 0)
|
|
558
|
+
const allUsers = await User.findMany({ limit: -1 });
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## 🛠️ Advanced Features
|
|
562
|
+
|
|
563
|
+
### Custom Middleware
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import { AbstractBaseMiddleware } from 'unconventional';
|
|
567
|
+
|
|
568
|
+
export class UserMiddleware extends AbstractBaseMiddleware<typeof User> {
|
|
569
|
+
constructor() {
|
|
570
|
+
super(User);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async findOwner(id: string | number): Promise<User | null> {
|
|
574
|
+
return this.findAncestor(id, (model) => model === User);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Error Handling
|
|
580
|
+
|
|
581
|
+
Unconventional includes a comprehensive error handling system:
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
import { APIError } from 'unconventional';
|
|
585
|
+
|
|
586
|
+
// Predefined errors
|
|
587
|
+
throw APIError.errNotFound('User not found');
|
|
588
|
+
throw APIError.errBadRequest('Invalid input');
|
|
589
|
+
throw APIError.errUnauthorized('Authentication required');
|
|
590
|
+
throw APIError.errForbidden('Access denied');
|
|
591
|
+
throw APIError.errResourceCreationFailed('Failed to create user');
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Upsert Operations
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
// Create or update based on unique constraint
|
|
598
|
+
const user = await User.create(
|
|
599
|
+
{ email: 'user@example.com', name: 'John' },
|
|
600
|
+
{
|
|
601
|
+
action: ConflictResolution.doUpdate,
|
|
602
|
+
constraint: 'email',
|
|
603
|
+
}
|
|
604
|
+
);
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
## 📖 Complete Example
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
// models/user.ts
|
|
611
|
+
import { BaseModel, prop, timestamp, RelationType } from 'unconventional';
|
|
612
|
+
import { Profile } from './profile';
|
|
613
|
+
|
|
614
|
+
@timestamp()
|
|
615
|
+
export class User extends BaseModel {
|
|
616
|
+
public static collection = 'users';
|
|
617
|
+
public static db: DB;
|
|
618
|
+
|
|
619
|
+
@prop({ required: true, unique: true })
|
|
620
|
+
public email!: string;
|
|
621
|
+
|
|
622
|
+
@prop({ required: true })
|
|
623
|
+
public name!: string;
|
|
624
|
+
|
|
625
|
+
@prop({ private: true })
|
|
626
|
+
public passwordHash?: string;
|
|
627
|
+
|
|
628
|
+
@prop({
|
|
629
|
+
relation: {
|
|
630
|
+
type: RelationType.HasOne,
|
|
631
|
+
model: Profile,
|
|
632
|
+
from: 'id',
|
|
633
|
+
to: 'userId',
|
|
634
|
+
},
|
|
635
|
+
})
|
|
636
|
+
public profile?: Profile;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// controllers/user.controller.ts
|
|
640
|
+
import { AbstractBaseController, APIError, BaseEnvKey } from 'unconventional';
|
|
641
|
+
import { User } from '../models/user';
|
|
642
|
+
|
|
643
|
+
export class UserController extends AbstractBaseController<typeof User> {
|
|
644
|
+
constructor() {
|
|
645
|
+
super(User, {
|
|
646
|
+
cache: true,
|
|
647
|
+
cacheTTL: 3600,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
protected async maskPrivateFields(req: RequestContext, response: User | User[]): Promise<void> {
|
|
652
|
+
User.maskPrivate(response);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
protected async preventIDOR(req: RequestContext): Promise<void> {
|
|
656
|
+
const ownerId = req.get(BaseEnvKey.ownerId);
|
|
657
|
+
const isPrivileged = req.get(BaseEnvKey.isPrivileged);
|
|
658
|
+
|
|
659
|
+
if (isPrivileged) return;
|
|
660
|
+
|
|
661
|
+
if (req.params.id) {
|
|
662
|
+
const user = await User.findById(req.params.id);
|
|
663
|
+
if (!user || user.id !== ownerId) {
|
|
664
|
+
throw APIError.errForbidden('Access denied');
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// server.ts
|
|
671
|
+
import { BackendServer, PGFactory } from 'unconventional';
|
|
672
|
+
import { UserController } from './controllers/user.controller';
|
|
673
|
+
|
|
674
|
+
const server = new BackendServer({
|
|
675
|
+
basePath: '/api',
|
|
676
|
+
getDB: PGFactory,
|
|
677
|
+
cors: {
|
|
678
|
+
origin: ['https://example.com'],
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const userController = new UserController();
|
|
683
|
+
|
|
684
|
+
server.app
|
|
685
|
+
.get('/users', userController.getAll())
|
|
686
|
+
.get('/users/:id', userController.get())
|
|
687
|
+
.post('/users', userController.create())
|
|
688
|
+
.put('/users/:id', userController.update())
|
|
689
|
+
.delete('/users/:id', userController.delete());
|
|
690
|
+
|
|
691
|
+
export default server.start();
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## 📚 API Reference
|
|
695
|
+
|
|
696
|
+
### BaseModel
|
|
697
|
+
|
|
698
|
+
#### Static Methods
|
|
699
|
+
|
|
700
|
+
| Method | Description | Returns |
|
|
701
|
+
|--------|-------------|---------|
|
|
702
|
+
| `create(data, upsertConfig?)` | Create a new record | `Promise<InstanceType<T> \| null>` |
|
|
703
|
+
| `createMany(data[], upsertConfig?)` | Create multiple records | `Promise<InstanceType<T>[] \| null>` |
|
|
704
|
+
| `findById(id, config?)` | Find by ID or key | `Promise<InstanceType<T> \| null>` |
|
|
705
|
+
| `findOne(config)` | Find a single record | `Promise<InstanceType<T> \| null>` |
|
|
706
|
+
| `findMany(config?)` | Find multiple records | `Promise<InstanceType<T>[]>` |
|
|
707
|
+
| `update(id, data)` | Update a record | `Promise<InstanceType<T> \| null>` |
|
|
708
|
+
| `updateMany(data[])` | Update multiple records | `Promise<InstanceType<T>[] \| null>` |
|
|
709
|
+
| `delete(id)` | Delete a record | `Promise<InstanceType<T> \| null>` |
|
|
710
|
+
| `deleteMany(config?)` | Delete multiple records | `Promise<InstanceType<T>[] \| null>` |
|
|
711
|
+
| `count()` | Count all records | `Promise<number \| null>` |
|
|
712
|
+
| `increment(id, data)` | Increment numeric fields | `Promise<InstanceType<T> \| null>` |
|
|
713
|
+
| `truncate()` | Delete all records | `Promise<void>` |
|
|
714
|
+
| `maskPrivate(data)` | Mask private fields | `void` |
|
|
715
|
+
|
|
716
|
+
#### Instance Methods
|
|
717
|
+
|
|
718
|
+
| Method | Description | Returns |
|
|
719
|
+
|--------|-------------|---------|
|
|
720
|
+
| `$id()` | Get the ID field value | `string \| number` |
|
|
721
|
+
| `$key()` | Get the key field value | `string \| undefined` |
|
|
722
|
+
| `$owner()` | Get the owner field value | `string \| number \| undefined` |
|
|
723
|
+
|
|
724
|
+
### BaseService
|
|
725
|
+
|
|
726
|
+
| Method | Description | Returns |
|
|
727
|
+
|--------|-------------|---------|
|
|
728
|
+
| `create(data, config?)` | Create with caching | `Promise<InstanceType<M>>` |
|
|
729
|
+
| `createMany(data[], config?)` | Create many with caching | `Promise<InstanceType<M>[]>` |
|
|
730
|
+
| `get(identifier, config?)` | Get with caching | `Promise<InstanceType<M>>` |
|
|
731
|
+
| `getAll(config?)` | Get all with pagination | `Promise<InstanceType<M>[]>` |
|
|
732
|
+
| `update(identifier, data, config?)` | Update with cache invalidation | `Promise<InstanceType<M>>` |
|
|
733
|
+
| `updateMany(data[], config?)` | Update many | `Promise<InstanceType<M>[]>` |
|
|
734
|
+
| `delete(identifier, config?)` | Delete with cache invalidation | `Promise<InstanceType<M>>` |
|
|
735
|
+
| `deleteMany(config?)` | Delete many | `Promise<InstanceType<M>[]>` |
|
|
736
|
+
|
|
737
|
+
### Configuration Types
|
|
738
|
+
|
|
739
|
+
#### ServerConfig
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
interface ServerConfig {
|
|
743
|
+
name?: string;
|
|
744
|
+
basePath?: string;
|
|
745
|
+
getDB: (ctx: Context) => DB;
|
|
746
|
+
cors?: CorsOptions;
|
|
747
|
+
middleware?: Middleware[];
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
#### ControllerOptions
|
|
752
|
+
|
|
753
|
+
```typescript
|
|
754
|
+
interface ControllerOptions {
|
|
755
|
+
cache?: boolean;
|
|
756
|
+
cacheTTL?: number;
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
#### ServiceConfig
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
interface ServiceConfig<M> {
|
|
764
|
+
cache?: KVNamespace;
|
|
765
|
+
cacheTTL?: number;
|
|
766
|
+
upsertConfig?: UpsertConfig<M>;
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
## 🤝 Contributing
|
|
771
|
+
|
|
772
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
773
|
+
|
|
774
|
+
1. Fork the repository
|
|
775
|
+
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
|
776
|
+
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
|
777
|
+
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
|
778
|
+
5. Open a Pull Request
|
|
779
|
+
|
|
780
|
+
## 📄 License
|
|
781
|
+
|
|
782
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
783
|
+
|
|
784
|
+
## 👤 Author
|
|
785
|
+
|
|
786
|
+
**Dean Mauro**
|
|
787
|
+
|
|
788
|
+
- GitHub: [@cloudflare-extension](https://github.com/cloudflare-extension)
|
|
789
|
+
- Repository: [unconventional](https://github.com/cloudflare-extension/unconventional)
|
|
790
|
+
|
|
791
|
+
## 🙏 Acknowledgments
|
|
792
|
+
|
|
793
|
+
- Built on [Hono](https://hono.dev/) - Ultrafast web framework
|
|
794
|
+
- Powered by [Cloudflare Workers](https://workers.cloudflare.com/) - Edge computing platform
|
|
795
|
+
- Database queries via [unconventional-pg-queries](https://github.com/cloudflare-extension/unconventional-pg-queries)
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
799
|
+
Made with ❤️ for the Cloudflare ecosystem
|
|
@@ -272,7 +272,7 @@ class AbstractBaseController {
|
|
|
272
272
|
}
|
|
273
273
|
return {
|
|
274
274
|
headers: ctx.req.raw.headers,
|
|
275
|
-
waitUntil: ctx.executionCtx.
|
|
275
|
+
waitUntil: ctx.executionCtx?.waitUntil.bind(ctx.executionCtx),
|
|
276
276
|
body: body,
|
|
277
277
|
params: ctx.req.param(),
|
|
278
278
|
queries: ctx.req.query(),
|
|
@@ -33,7 +33,7 @@ export declare class BaseModel extends Document {
|
|
|
33
33
|
static updateMany<T extends typeof BaseModel>(this: T, data: Partial<InstanceType<T>>[]): Promise<InstanceType<T>[] | null>;
|
|
34
34
|
static delete<T extends typeof BaseModel>(this: T, id: string | number): Promise<InstanceType<T> | null>;
|
|
35
35
|
static deleteMany<T extends typeof BaseModel>(this: T, config?: FilterConfig): Promise<InstanceType<T>[] | null>;
|
|
36
|
-
static count<T extends typeof BaseModel>(this: T): Promise<number | null>;
|
|
36
|
+
static count<T extends typeof BaseModel>(this: T, config?: FilterConfig): Promise<number | null>;
|
|
37
37
|
static increment<T extends typeof BaseModel>(this: T, id: string | number, data: {
|
|
38
38
|
[key in keyof InstanceType<T>]?: number;
|
|
39
39
|
}): Promise<InstanceType<T> | null>;
|
package/dist/core/base.model.js
CHANGED
|
@@ -162,11 +162,15 @@ class BaseModel extends Document {
|
|
|
162
162
|
return null;
|
|
163
163
|
return response.map(item => this.fromDatabaseJson(item));
|
|
164
164
|
}
|
|
165
|
-
static async count() {
|
|
165
|
+
static async count(config) {
|
|
166
|
+
const where = (0, db_utils_1.getWhere)(this, config?.filter);
|
|
167
|
+
const expand = (0, db_utils_1.expandRelationalFilters)(this, where, (0, db_utils_1.getExpand)(this, config?.expand));
|
|
166
168
|
const response = await this.db.fetch({
|
|
167
169
|
action: db_types_1.SqlAction.Select,
|
|
168
170
|
type: unconventional_pg_queries_1.OneOrMany.Many,
|
|
169
171
|
table: this.collection,
|
|
172
|
+
expand,
|
|
173
|
+
where,
|
|
170
174
|
subAction: unconventional_pg_queries_1.SubAction.Count
|
|
171
175
|
});
|
|
172
176
|
if (!response)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unconventional",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "A complete server built on Cloudflare Workers/Pages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"hono": "^4",
|
|
49
|
-
"unconventional-pg-queries": "^1.
|
|
49
|
+
"unconventional-pg-queries": "^1.7.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@cloudflare/workers-types": "^4.20231121.0",
|
|
@@ -55,6 +55,6 @@
|
|
|
55
55
|
"hono": "^4",
|
|
56
56
|
"tsx": "^4.20.6",
|
|
57
57
|
"typescript": "^5.3.3",
|
|
58
|
-
"unconventional-pg-queries": "^1.
|
|
58
|
+
"unconventional-pg-queries": "^1.7.0"
|
|
59
59
|
}
|
|
60
60
|
}
|