outlet-orm 6.5.0 → 7.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/bin/init.js +122 -0
- package/bin/mcp.js +78 -0
- package/bin/migrate.js +25 -0
- package/docs/skills/outlet-orm/ADVANCED.md +575 -0
- package/docs/skills/outlet-orm/AI.md +220 -0
- package/docs/skills/outlet-orm/API.md +522 -0
- package/docs/skills/outlet-orm/BACKUP.md +150 -0
- package/docs/skills/outlet-orm/MIGRATIONS.md +605 -0
- package/docs/skills/outlet-orm/MODELS.md +427 -0
- package/docs/skills/outlet-orm/QUERIES.md +345 -0
- package/docs/skills/outlet-orm/RELATIONS.md +555 -0
- package/docs/skills/outlet-orm/SECURITY.md +386 -0
- package/docs/skills/outlet-orm/SEEDS.md +98 -0
- package/docs/skills/outlet-orm/SKILL.md +205 -0
- package/docs/skills/outlet-orm/TYPESCRIPT.md +480 -0
- package/package.json +7 -3
- package/src/AI/AISafetyGuardrails.js +146 -0
- package/src/AI/MCPServer.js +685 -0
- package/src/AI/PromptGenerator.js +318 -0
- package/src/index.js +11 -1
- package/types/index.d.ts +106 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# Outlet ORM - TypeScript Best Practices (v5.0.0)
|
|
2
|
+
|
|
3
|
+
[← Back to Index](SKILL.md) | [Previous: Advanced](ADVANCED.md)
|
|
4
|
+
|
|
5
|
+
> 🆕 **v5.0.0**: Full support for generics with`Model<TAttributes>`, Typed Schema Builder,`MigrationInterface`, and recommended layered architecture.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What's New in v4.0.0
|
|
10
|
+
|
|
11
|
+
| Feature | Description |
|
|
12
|
+
|---------|-------------|
|
|
13
|
+
| **Generic Model** |`Model<TAttributes>`for typed attributes |
|
|
14
|
+
| **Type-safe getAttribute** | Returns correct type based on your interface |
|
|
15
|
+
| **Schema Builder** | Complete interfaces for typed migrations |
|
|
16
|
+
| **MigrationInterface** | Standard interface for TypeScript migrations |
|
|
17
|
+
| **ValidationRule** | Extended with`url`,`array`,`integer`,`alpha`, etc. |
|
|
18
|
+
| **ModelEventName** | Union type for all model events |
|
|
19
|
+
| **WhereOperator** | Union type for all comparison operators |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Recommended Project Structure (Layered Architecture)
|
|
24
|
+
|
|
25
|
+
> 🔐 **Security**: See the Security Guide for TypeScript security patterns.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
my-project/
|
|
29
|
+
├── .env # ⚠️ NEVER commit
|
|
30
|
+
├── .gitignore
|
|
31
|
+
├── package.json
|
|
32
|
+
├── tsconfig.json
|
|
33
|
+
├── src/
|
|
34
|
+
│ ├── index.ts # Entry point
|
|
35
|
+
│ ├── controllers/ # 🎮 HTTP handling only
|
|
36
|
+
│ │ └── UserController.ts
|
|
37
|
+
│ ├── services/ # ⚙️ Business logic
|
|
38
|
+
│ │ └── UserService.ts
|
|
39
|
+
│ ├── repositories/ # 📦 Data access layer
|
|
40
|
+
│ │ └── UserRepository.ts
|
|
41
|
+
│ ├── models/ # 📊 Typed Model classes
|
|
42
|
+
│ │ ├── User.ts # hidden: ['password']
|
|
43
|
+
│ │ └── Post.ts
|
|
44
|
+
│ ├── middlewares/ # 🔒 Auth, validation
|
|
45
|
+
│ │ ├── auth.ts
|
|
46
|
+
│ │ └── validator.ts
|
|
47
|
+
│ ├── config/ # 🔒 Configuration
|
|
48
|
+
│ │ └── security.ts
|
|
49
|
+
│ ├── utils/ # 🔒 Hash, tokens
|
|
50
|
+
│ │ ├── hash.ts
|
|
51
|
+
│ │ └── token.ts
|
|
52
|
+
│ └── types/ # Custom TypeScript types
|
|
53
|
+
├── database/
|
|
54
|
+
│ └── migrations/
|
|
55
|
+
├── public/ # ✅ Only public folder
|
|
56
|
+
├── logs/ # 📋 Not versioned
|
|
57
|
+
└── tests/
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## TypeScript Configuration
|
|
63
|
+
|
|
64
|
+
### tsconfig.json
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"compilerOptions": {
|
|
69
|
+
"target": "ES2020",
|
|
70
|
+
"module": "commonjs",
|
|
71
|
+
"lib": ["ES2020"],
|
|
72
|
+
"strict": true,
|
|
73
|
+
"esModuleInterop": true,
|
|
74
|
+
"skipLibCheck": true,
|
|
75
|
+
"forceConsistentCasingInFileNames": true,
|
|
76
|
+
"declaration": true,
|
|
77
|
+
"outDir": "./dist",
|
|
78
|
+
"rootDir": "./src"
|
|
79
|
+
},
|
|
80
|
+
"include": ["src/**/*"],
|
|
81
|
+
"exclude": ["node_modules", "dist"]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Typed Model Definition
|
|
88
|
+
|
|
89
|
+
### Generic Model (v4.0.0+)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { Model, HasManyRelation, HasOneRelation } from 'outlet-orm';
|
|
93
|
+
|
|
94
|
+
// Define attribute interface
|
|
95
|
+
interface UserAttributes {
|
|
96
|
+
id: number;
|
|
97
|
+
name: string;
|
|
98
|
+
email: string;
|
|
99
|
+
password: string;
|
|
100
|
+
age?: number;
|
|
101
|
+
role: 'admin' | 'user' | 'moderator';
|
|
102
|
+
created_at: Date;
|
|
103
|
+
updated_at: Date;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Use generic Model<TAttributes>
|
|
107
|
+
class User extends Model<UserAttributes> {
|
|
108
|
+
static readonly table = 'users';
|
|
109
|
+
static readonly primaryKey = 'id';
|
|
110
|
+
static readonly timestamps = true;
|
|
111
|
+
|
|
112
|
+
static readonly fillable = ['name', 'email', 'password', 'age', 'role'];
|
|
113
|
+
static readonly hidden = ['password'];
|
|
114
|
+
|
|
115
|
+
static readonly casts = {
|
|
116
|
+
id: 'int' as const,
|
|
117
|
+
age: 'int' as const,
|
|
118
|
+
created_at: 'date' as const,
|
|
119
|
+
updated_at: 'date' as const
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Typed relationships
|
|
123
|
+
posts(): HasManyRelation<Post> {
|
|
124
|
+
return this.hasMany(Post, 'user_id');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
profile(): HasOneRelation<Profile> {
|
|
128
|
+
return this.hasOne(Profile, 'user_id');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Type-safe getAttribute/setAttribute
|
|
133
|
+
const user = await User.find(1);
|
|
134
|
+
if (user) {
|
|
135
|
+
const name: string = user.getAttribute('name'); // ✅ Type inferred
|
|
136
|
+
const role = user.getAttribute('role'); // ✅ Type: 'admin' | 'user' | 'moderator'
|
|
137
|
+
|
|
138
|
+
user.setAttribute('name', 'New Name'); // ✅ Type-safe
|
|
139
|
+
// user.setAttribute('role', 'invalid'); // ❌ TypeScript error
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default User;
|
|
143
|
+
export type { UserAttributes };
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Using`as const`for Casts
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// ✅ CORRECT - Preserves literal types
|
|
150
|
+
static readonly casts = {
|
|
151
|
+
id: 'int' as const,
|
|
152
|
+
email_verified: 'boolean' as const,
|
|
153
|
+
metadata: 'json' as const
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ❌ WRONG - Inferred as string
|
|
157
|
+
static casts = {
|
|
158
|
+
id: 'int', // Type: string (not 'int')
|
|
159
|
+
};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Query Builder with Types
|
|
165
|
+
|
|
166
|
+
### Type-Safe Queries
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import User from './models/User';
|
|
170
|
+
|
|
171
|
+
async function getActiveUsers(): Promise<User[]> {
|
|
172
|
+
return User
|
|
173
|
+
.where('status', 'active')
|
|
174
|
+
.where('age', '>', 18)
|
|
175
|
+
.orderBy('created_at', 'desc')
|
|
176
|
+
.limit(10)
|
|
177
|
+
.get();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Pagination returns typed result
|
|
181
|
+
async function getPaginatedUsers(page: number) {
|
|
182
|
+
const result = await User.paginate(page, 15);
|
|
183
|
+
// result.data is User[]
|
|
184
|
+
// result.total is number
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Eager Loading with Types
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Load relationships
|
|
193
|
+
const users = await User.with('posts', 'profile').get();
|
|
194
|
+
|
|
195
|
+
// With constraints
|
|
196
|
+
const usersWithRecentPosts = await User.with({
|
|
197
|
+
posts: (qb) => qb.where('created_at', '>', '2024-01-01').orderBy('id', 'desc')
|
|
198
|
+
}).get();
|
|
199
|
+
|
|
200
|
+
// Access relationships
|
|
201
|
+
users.forEach(user => {
|
|
202
|
+
const posts = user.relationships.posts as Post[];
|
|
203
|
+
console.log(`${user.getAttribute('name')} has ${posts.length} posts`);
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Typed Migrations
|
|
210
|
+
|
|
211
|
+
### Migration Interface
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import { SchemaBuilder } from 'outlet-orm';
|
|
215
|
+
|
|
216
|
+
interface Migration {
|
|
217
|
+
up(schema: SchemaBuilder): Promise<void>;
|
|
218
|
+
down(schema: SchemaBuilder): Promise<void>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const migration: Migration = {
|
|
222
|
+
async up(schema) {
|
|
223
|
+
await schema.createTable('users', (table) => {
|
|
224
|
+
table.id();
|
|
225
|
+
table.string('name', 100);
|
|
226
|
+
table.string('email', 255).unique();
|
|
227
|
+
table.string('password', 255);
|
|
228
|
+
table.integer('age').nullable();
|
|
229
|
+
table.enum('role', ['admin', 'user', 'moderator']).default('user');
|
|
230
|
+
table.timestamps();
|
|
231
|
+
|
|
232
|
+
table.index(['email']);
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async down(schema) {
|
|
237
|
+
await schema.dropTableIfExists('users');
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export = migration;
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Validation with Types
|
|
247
|
+
|
|
248
|
+
### Typed Validation Rules
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { Model, ValidationRule } from 'outlet-orm';
|
|
252
|
+
|
|
253
|
+
class User extends Model {
|
|
254
|
+
static readonly table = 'users';
|
|
255
|
+
|
|
256
|
+
// Type-safe validation rules
|
|
257
|
+
static readonly rules: Record<string, string> = {
|
|
258
|
+
name: 'required|string|min:2|max:100',
|
|
259
|
+
email: 'required|email',
|
|
260
|
+
password: 'required|string|min:8',
|
|
261
|
+
age: 'integer|min:0|max:150',
|
|
262
|
+
website: 'url',
|
|
263
|
+
role: 'in:admin,user,moderator'
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Validate before save
|
|
268
|
+
const user = new User({ name: 'J', email: 'invalid' });
|
|
269
|
+
const result = user.validate();
|
|
270
|
+
|
|
271
|
+
if (!result.valid) {
|
|
272
|
+
console.error(result.errors);
|
|
273
|
+
// { name: ['min:2'], email: ['email'] }
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Events with Types
|
|
280
|
+
|
|
281
|
+
### Typed Event Callbacks
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
import { Model, EventCallback } from 'outlet-orm';
|
|
285
|
+
|
|
286
|
+
class User extends Model {
|
|
287
|
+
static readonly table = 'users';
|
|
288
|
+
|
|
289
|
+
static boot() {
|
|
290
|
+
// Type-safe event registration
|
|
291
|
+
this.creating((model: User) => {
|
|
292
|
+
// Hash password before create
|
|
293
|
+
const password = model.getAttribute('password');
|
|
294
|
+
model.setAttribute('password', hashPassword(password));
|
|
295
|
+
return true; // Continue
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
this.deleting((model: User) => {
|
|
299
|
+
// Prevent admin deletion
|
|
300
|
+
if (model.getAttribute('role') === 'admin') {
|
|
301
|
+
return false; // Cancel deletion
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Relations Type Reference
|
|
312
|
+
|
|
313
|
+
| Relation | Return Type | Usage |
|
|
314
|
+
|----------|-------------|-------|
|
|
315
|
+
|`hasOne`|`HasOneRelation<T>`|`this.hasOne(Profile, 'user_id')`|
|
|
316
|
+
|`hasMany`|`HasManyRelation<T>`|`this.hasMany(Post, 'user_id')`|
|
|
317
|
+
|`belongsTo`|`BelongsToRelation<T>`|`this.belongsTo(User, 'user_id')`|
|
|
318
|
+
|`belongsToMany`|`BelongsToManyRelation<T>`|`this.belongsToMany(Role, 'user_roles')`|
|
|
319
|
+
|`hasManyThrough`|`HasManyThroughRelation<T>`|`this.hasManyThrough(Post, User)`|
|
|
320
|
+
|`hasOneThrough`|`HasOneThroughRelation<T>`|`this.hasOneThrough(Owner, Car)`|
|
|
321
|
+
|`morphOne`|`MorphOneRelation<T>`|`this.morphOne(Image, 'imageable')`|
|
|
322
|
+
|`morphMany`|`MorphManyRelation<T>`|`this.morphMany(Comment, 'commentable')`|
|
|
323
|
+
|`morphTo`|`MorphToRelation<T>`|`this.morphTo()`|
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Typed Migrations (v4.0.0+)
|
|
328
|
+
|
|
329
|
+
### MigrationInterface
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
import { MigrationInterface, Schema, TableBuilder } from 'outlet-orm';
|
|
333
|
+
|
|
334
|
+
export const migration: MigrationInterface = {
|
|
335
|
+
name: 'create_users_table',
|
|
336
|
+
|
|
337
|
+
async up(): Promise<void> {
|
|
338
|
+
await Schema.create('users', (table: TableBuilder) => {
|
|
339
|
+
table.id();
|
|
340
|
+
table.string('name');
|
|
341
|
+
table.string('email').unique();
|
|
342
|
+
table.string('password');
|
|
343
|
+
table.enum('role', ['admin', 'user', 'moderator']).default('user');
|
|
344
|
+
table.timestamps();
|
|
345
|
+
table.softDeletes();
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
async down(): Promise<void> {
|
|
350
|
+
await Schema.dropIfExists('users');
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### TableBuilder Methods
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// Column types
|
|
359
|
+
table.id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
|
|
360
|
+
table.string('name', 100); // VARCHAR(100)
|
|
361
|
+
table.text('content'); // TEXT
|
|
362
|
+
table.integer('age').unsigned(); // INT UNSIGNED
|
|
363
|
+
table.decimal('price', 10, 2); // DECIMAL(10,2)
|
|
364
|
+
table.boolean('is_active').default(true);
|
|
365
|
+
table.json('settings').nullable();
|
|
366
|
+
table.enum('status', ['draft', 'published']);
|
|
367
|
+
table.timestamps(); // created_at, updated_at
|
|
368
|
+
table.softDeletes(); // deleted_at
|
|
369
|
+
|
|
370
|
+
// Modifiers
|
|
371
|
+
table.string('email').unique();
|
|
372
|
+
table.integer('views').default(0);
|
|
373
|
+
table.text('bio').nullable();
|
|
374
|
+
table.string('phone').after('email');
|
|
375
|
+
|
|
376
|
+
// Foreign keys
|
|
377
|
+
table.unsignedBigInteger('user_id');
|
|
378
|
+
table.foreign('user_id')
|
|
379
|
+
.references('id')
|
|
380
|
+
.on('users')
|
|
381
|
+
.onDelete('CASCADE')
|
|
382
|
+
.onUpdate('CASCADE');
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Common Patterns
|
|
388
|
+
|
|
389
|
+
### Repository Pattern
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import User from './models/User';
|
|
393
|
+
import { PaginationResult } from 'outlet-orm';
|
|
394
|
+
|
|
395
|
+
class UserRepository {
|
|
396
|
+
async findById(id: number): Promise<User | null> {
|
|
397
|
+
return User.find(id);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async findByEmail(email: string): Promise<User | null> {
|
|
401
|
+
return User.where('email', email).first();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async create(data: Partial<UserAttributes>): Promise<User> {
|
|
405
|
+
return User.create(data);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async paginate(page: number, perPage = 15): Promise<PaginationResult<User>> {
|
|
409
|
+
return User.orderBy('created_at', 'desc').paginate(page, perPage);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async delete(id: number): Promise<boolean> {
|
|
413
|
+
const user = await User.find(id);
|
|
414
|
+
if (user) {
|
|
415
|
+
return user.destroy();
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export default new UserRepository();
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Service Layer
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
import User from './models/User';
|
|
428
|
+
import { DatabaseConnection } from 'outlet-orm';
|
|
429
|
+
|
|
430
|
+
class UserService {
|
|
431
|
+
async createWithProfile(
|
|
432
|
+
userData: Partial<UserAttributes>,
|
|
433
|
+
profileData: Partial<ProfileAttributes>
|
|
434
|
+
): Promise<User> {
|
|
435
|
+
const db = User.getConnection();
|
|
436
|
+
|
|
437
|
+
return db.transaction(async () => {
|
|
438
|
+
const user = await User.create(userData);
|
|
439
|
+
|
|
440
|
+
await Profile.create({
|
|
441
|
+
...profileData,
|
|
442
|
+
user_id: user.getAttribute('id')
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Load profile relation
|
|
446
|
+
await user.load('profile');
|
|
447
|
+
return user;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## Troubleshooting
|
|
456
|
+
|
|
457
|
+
### Type Errors
|
|
458
|
+
|
|
459
|
+
| Error | Cause | Solution |
|
|
460
|
+
|-------|-------|----------|
|
|
461
|
+
|`Property 'xxx' does not exist`| Missing attribute in interface | Add to interface or use`as any`|
|
|
462
|
+
|`Type 'string' is not assignable`| Missing`as const`on casts | Add`as const`to cast values |
|
|
463
|
+
|`Cannot find module 'outlet-orm'`| Types not found | Check`types`in package.json |
|
|
464
|
+
|
|
465
|
+
### Best Practices
|
|
466
|
+
|
|
467
|
+
1. **Always use`as const`** for static properties with literal types
|
|
468
|
+
2. **Define attribute interfaces** for each model
|
|
469
|
+
3. **Export types** alongside models for reuse
|
|
470
|
+
4. **Use strict mode** in tsconfig.json
|
|
471
|
+
5. **Type relation methods** explicitly for better IDE support
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## References
|
|
476
|
+
|
|
477
|
+
- [TypeScript Documentation](TYPESCRIPT.md)
|
|
478
|
+
- [Model Guide](MODELS.md)
|
|
479
|
+
- [Relationship Guide](RELATIONS.md)
|
|
480
|
+
- [Migration Guide](MIGRATIONS.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "outlet-orm",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "A Laravel Eloquent-inspired ORM for Node.js with support for MySQL, PostgreSQL, and SQLite",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
"outlet-init": "bin/init.js",
|
|
9
9
|
"outlet-convert": "bin/convert.js",
|
|
10
10
|
"outlet-migrate": "bin/migrate.js",
|
|
11
|
-
"outlet-reverse": "bin/reverse.js"
|
|
11
|
+
"outlet-reverse": "bin/reverse.js",
|
|
12
|
+
"outlet-mcp": "bin/mcp.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src/**",
|
|
15
16
|
"bin/**",
|
|
16
17
|
"types/**",
|
|
18
|
+
"docs/skills/**",
|
|
17
19
|
"README.md",
|
|
18
20
|
"LICENSE"
|
|
19
21
|
],
|
|
@@ -44,7 +46,9 @@
|
|
|
44
46
|
"postgresql",
|
|
45
47
|
"sqlite",
|
|
46
48
|
"query-builder",
|
|
47
|
-
"active-record"
|
|
49
|
+
"active-record",
|
|
50
|
+
"mcp",
|
|
51
|
+
"ai-agent"
|
|
48
52
|
],
|
|
49
53
|
"author": "omgbwa-yasse",
|
|
50
54
|
"license": "MIT",
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outlet ORM — AI Safety Guardrails
|
|
3
|
+
* Detects AI agent invocations and protects against destructive operations.
|
|
4
|
+
*
|
|
5
|
+
* Detects: Cursor, Claude Code, Gemini CLI, GitHub Copilot, Windsurf,
|
|
6
|
+
* Aider, Replit, Qwen Code, and generic MCP clients.
|
|
7
|
+
*
|
|
8
|
+
* @since 7.0.0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Known AI agent environment signatures ──────────────────────
|
|
12
|
+
|
|
13
|
+
const AI_AGENT_SIGNATURES = [
|
|
14
|
+
{ name: 'Cursor', env: 'CURSOR_TRACE_ID' },
|
|
15
|
+
{ name: 'Cursor', env: 'CURSOR_SESSION_ID' },
|
|
16
|
+
{ name: 'Claude Code', env: 'CLAUDE_CODE' },
|
|
17
|
+
{ name: 'Claude Code', env: 'ANTHROPIC_API_KEY', processTitle: 'claude' },
|
|
18
|
+
{ name: 'Gemini CLI', env: 'GEMINI_API_KEY', processTitle: 'gemini' },
|
|
19
|
+
{ name: 'GitHub Copilot', env: 'GITHUB_COPILOT' },
|
|
20
|
+
{ name: 'GitHub Copilot', env: 'COPILOT_AGENT_MODE' },
|
|
21
|
+
{ name: 'Windsurf', env: 'WINDSURF_SESSION_ID' },
|
|
22
|
+
{ name: 'Windsurf', env: 'CODEIUM_SESSION' },
|
|
23
|
+
{ name: 'Aider', env: 'AIDER_SESSION' },
|
|
24
|
+
{ name: 'Replit', env: 'REPLIT_DB_URL' },
|
|
25
|
+
{ name: 'Replit AI', env: 'REPLIT_AI_ENABLED' },
|
|
26
|
+
{ name: 'Qwen Code', env: 'QWEN_SESSION' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// ─── Destructive commands ────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const DESTRUCTIVE_COMMANDS = new Set([
|
|
32
|
+
'reset', 'fresh', 'migrate:reset', 'migrate:fresh',
|
|
33
|
+
'drop', 'truncate', 'restore'
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// ─── Module ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class AISafetyGuardrails {
|
|
39
|
+
/**
|
|
40
|
+
* Detect whether the current process is being invoked by an AI agent.
|
|
41
|
+
* @returns {{ detected: boolean, agentName: string|null }}
|
|
42
|
+
*/
|
|
43
|
+
static detectAgent() {
|
|
44
|
+
const env = process.env || {};
|
|
45
|
+
|
|
46
|
+
for (const sig of AI_AGENT_SIGNATURES) {
|
|
47
|
+
if (env[sig.env]) {
|
|
48
|
+
// Some signatures need both env var AND process title match
|
|
49
|
+
if (sig.processTitle) {
|
|
50
|
+
const title = (process.title || '').toLowerCase();
|
|
51
|
+
const argv = (process.argv || []).join(' ').toLowerCase();
|
|
52
|
+
if (title.includes(sig.processTitle) || argv.includes(sig.processTitle)) {
|
|
53
|
+
return { detected: true, agentName: sig.name };
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
return { detected: true, agentName: sig.name };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check for MCP-related indicators
|
|
62
|
+
if (env.MCP_SERVER_NAME || env.MCP_SESSION_ID) {
|
|
63
|
+
return { detected: true, agentName: 'MCP Client' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { detected: false, agentName: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a CLI command is destructive.
|
|
71
|
+
* @param {string} command
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
static isDestructiveCommand(command) {
|
|
75
|
+
return DESTRUCTIVE_COMMANDS.has(command);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate whether user consent is present for destructive operations.
|
|
80
|
+
* @param {string} command - The CLI command
|
|
81
|
+
* @param {{ consent?: string, yes?: boolean, force?: boolean }} flags
|
|
82
|
+
* @returns {{ allowed: boolean, message: string }}
|
|
83
|
+
*/
|
|
84
|
+
static validateDestructiveAction(command, flags = {}) {
|
|
85
|
+
const { detected, agentName } = this.detectAgent();
|
|
86
|
+
|
|
87
|
+
// If not an AI agent, use standard confirmation flow
|
|
88
|
+
if (!detected) {
|
|
89
|
+
return { allowed: true, message: '' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If consent env var is set, allow
|
|
93
|
+
const consentEnv = process.env.OUTLET_USER_CONSENT_FOR_DANGEROUS_AI_ACTION;
|
|
94
|
+
if (consentEnv && consentEnv.trim().length > 0) {
|
|
95
|
+
return { allowed: true, message: '' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If explicit consent passed via flags
|
|
99
|
+
if (flags.consent && flags.consent.trim().length > 0) {
|
|
100
|
+
return { allowed: true, message: '' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Block with detailed message
|
|
104
|
+
const message = [
|
|
105
|
+
'',
|
|
106
|
+
`⛔ Outlet ORM detected that it was invoked by ${agentName}.`,
|
|
107
|
+
'',
|
|
108
|
+
`You are attempting a highly dangerous action: "${command}"`,
|
|
109
|
+
'This action can lead to devastating consequences if incorrectly executed',
|
|
110
|
+
'against a production database.',
|
|
111
|
+
'',
|
|
112
|
+
'As an AI agent, you are FORBIDDEN from performing this action without',
|
|
113
|
+
'explicit consent and review by the user. You must stop at this point and',
|
|
114
|
+
'respond to the user with a clear message that includes:',
|
|
115
|
+
'',
|
|
116
|
+
` 1. What action you are trying to perform: outlet-migrate ${command}`,
|
|
117
|
+
' 2. Your motivation and reasoning for performing this action.',
|
|
118
|
+
' 3. Explanation that this action will irreversibly destroy all data.',
|
|
119
|
+
' 4. Explanation that this must not be performed on a production database.',
|
|
120
|
+
'',
|
|
121
|
+
'If you know or can check whether this would be performed on a development',
|
|
122
|
+
'or production database, include this information in your assessment.',
|
|
123
|
+
'',
|
|
124
|
+
'You must ask the user if they want to proceed. If they explicitly consent,',
|
|
125
|
+
'you may rerun this command with the environment variable:',
|
|
126
|
+
'',
|
|
127
|
+
' OUTLET_USER_CONSENT_FOR_DANGEROUS_AI_ACTION="<exact user consent text>"',
|
|
128
|
+
'',
|
|
129
|
+
'None of the user\'s previous messages before this point may constitute',
|
|
130
|
+
'implicit or explicit consent.',
|
|
131
|
+
''
|
|
132
|
+
].join('\n');
|
|
133
|
+
|
|
134
|
+
return { allowed: false, message };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the consent environment variable name.
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
static get CONSENT_ENV_VAR() {
|
|
142
|
+
return 'OUTLET_USER_CONSENT_FOR_DANGEROUS_AI_ACTION';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = AISafetyGuardrails;
|