outlet-orm 6.0.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/README.md +4 -2
- 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/Model.js +154 -2
- package/src/QueryBuilder.js +82 -0
- package/src/index.js +11 -1
- package/types/index.d.ts +147 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outlet ORM — Prompt-based Model Generator
|
|
3
|
+
* Parses natural language descriptions and generates models + migrations.
|
|
4
|
+
*
|
|
5
|
+
* Used by `outlet-init --prompt "..."` to bootstrap projects from descriptions.
|
|
6
|
+
*
|
|
7
|
+
* @since 7.0.0
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
// ─── Domain pattern recognition ──────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DOMAIN_PATTERNS = {
|
|
16
|
+
// E-commerce
|
|
17
|
+
'e-?commerce|shop|store|product|cart|order|payment|checkout': {
|
|
18
|
+
tables: {
|
|
19
|
+
users: { columns: ['name:string', 'email:string:unique', 'password:string', 'role:string:default(customer)'] },
|
|
20
|
+
products: { columns: ['name:string', 'description:text:nullable', 'price:decimal(10,2)', 'stock:integer:default(0)', 'sku:string:unique', 'category_id:foreignId'] },
|
|
21
|
+
categories: { columns: ['name:string', 'slug:string:unique', 'parent_id:integer:nullable'] },
|
|
22
|
+
orders: { columns: ['user_id:foreignId', 'status:string:default(pending)', 'total:decimal(10,2)', 'shipping_address:text'] },
|
|
23
|
+
order_items:{ columns: ['order_id:foreignId', 'product_id:foreignId', 'quantity:integer', 'price:decimal(10,2)'] },
|
|
24
|
+
payments: { columns: ['order_id:foreignId', 'method:string', 'amount:decimal(10,2)', 'status:string:default(pending)', 'transaction_id:string:nullable'] }
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// Blog / CMS
|
|
29
|
+
'blog|article|post|cms|content|comment|tag': {
|
|
30
|
+
tables: {
|
|
31
|
+
users: { columns: ['name:string', 'email:string:unique', 'password:string', 'bio:text:nullable', 'avatar:string:nullable'] },
|
|
32
|
+
posts: { columns: ['user_id:foreignId', 'title:string', 'slug:string:unique', 'content:text', 'excerpt:text:nullable', 'status:string:default(draft)', 'published_at:timestamp:nullable'] },
|
|
33
|
+
categories: { columns: ['name:string', 'slug:string:unique', 'description:text:nullable'] },
|
|
34
|
+
tags: { columns: ['name:string', 'slug:string:unique'] },
|
|
35
|
+
post_tag: { columns: ['post_id:foreignId', 'tag_id:foreignId'], pivot: true },
|
|
36
|
+
comments: { columns: ['post_id:foreignId', 'user_id:foreignId:nullable', 'author_name:string:nullable', 'body:text', 'approved:boolean:default(false)'] }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// Task / Project management
|
|
41
|
+
'task|project|todo|kanban|board|sprint|ticket': {
|
|
42
|
+
tables: {
|
|
43
|
+
users: { columns: ['name:string', 'email:string:unique', 'password:string', 'avatar:string:nullable'] },
|
|
44
|
+
projects: { columns: ['name:string', 'description:text:nullable', 'owner_id:foreignId', 'status:string:default(active)'] },
|
|
45
|
+
tasks: { columns: ['project_id:foreignId', 'assigned_to:integer:nullable', 'title:string', 'description:text:nullable', 'status:string:default(todo)', 'priority:string:default(medium)', 'due_date:date:nullable'] },
|
|
46
|
+
labels: { columns: ['name:string', 'color:string:default(#3498db)'] },
|
|
47
|
+
task_label: { columns: ['task_id:foreignId', 'label_id:foreignId'], pivot: true }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Social network
|
|
52
|
+
'social|friend|follow|like|feed|profile|message|chat': {
|
|
53
|
+
tables: {
|
|
54
|
+
users: { columns: ['name:string', 'email:string:unique', 'password:string', 'username:string:unique', 'bio:text:nullable', 'avatar:string:nullable'] },
|
|
55
|
+
posts: { columns: ['user_id:foreignId', 'content:text', 'media_url:string:nullable', 'visibility:string:default(public)'] },
|
|
56
|
+
comments: { columns: ['post_id:foreignId', 'user_id:foreignId', 'body:text'] },
|
|
57
|
+
likes: { columns: ['user_id:foreignId', 'likeable_id:integer', 'likeable_type:string'] },
|
|
58
|
+
follows: { columns: ['follower_id:foreignId', 'following_id:foreignId'] },
|
|
59
|
+
messages: { columns: ['sender_id:foreignId', 'receiver_id:foreignId', 'body:text', 'read_at:timestamp:nullable'] }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// SaaS / Multi-tenant
|
|
64
|
+
'saas|tenant|subscription|plan|billing|organization': {
|
|
65
|
+
tables: {
|
|
66
|
+
organizations: { columns: ['name:string', 'slug:string:unique', 'plan_id:foreignId:nullable'] },
|
|
67
|
+
users: { columns: ['organization_id:foreignId', 'name:string', 'email:string:unique', 'password:string', 'role:string:default(member)'] },
|
|
68
|
+
plans: { columns: ['name:string', 'slug:string:unique', 'price:decimal(8,2)', 'features:json:nullable', 'max_users:integer:default(5)'] },
|
|
69
|
+
subscriptions: { columns: ['organization_id:foreignId', 'plan_id:foreignId', 'status:string:default(active)', 'starts_at:timestamp', 'ends_at:timestamp:nullable', 'trial_ends_at:timestamp:nullable'] },
|
|
70
|
+
invoices: { columns: ['subscription_id:foreignId', 'amount:decimal(10,2)', 'status:string:default(pending)', 'paid_at:timestamp:nullable'] }
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Habit tracker / Health
|
|
75
|
+
'habit|tracker|health|fitness|goal|streak|log': {
|
|
76
|
+
tables: {
|
|
77
|
+
users: { columns: ['name:string', 'email:string:unique', 'password:string', 'timezone:string:default(UTC)'] },
|
|
78
|
+
habits: { columns: ['user_id:foreignId', 'name:string', 'description:text:nullable', 'frequency:string:default(daily)', 'color:string:default(#3498db)', 'target:integer:default(1)'] },
|
|
79
|
+
logs: { columns: ['habit_id:foreignId', 'date:date', 'completed:boolean:default(false)', 'value:integer:default(0)', 'notes:text:nullable'] },
|
|
80
|
+
goals: { columns: ['user_id:foreignId', 'title:string', 'target_value:integer', 'current_value:integer:default(0)', 'deadline:date:nullable'] }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// API / Auth / Generic
|
|
85
|
+
'api|auth|rest|user': {
|
|
86
|
+
tables: {
|
|
87
|
+
users: { columns: ['name:string', 'email:string:unique', 'password:string', 'role:string:default(user)', 'email_verified_at:timestamp:nullable'] },
|
|
88
|
+
tokens: { columns: ['user_id:foreignId', 'token:string:unique', 'type:string:default(api)', 'expires_at:timestamp:nullable'] },
|
|
89
|
+
password_resets:{ columns: ['email:string', 'token:string', 'created_at:timestamp'] }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ─── Generator ──────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
class PromptGenerator {
|
|
97
|
+
/**
|
|
98
|
+
* Parse a natural language prompt and return a project blueprint.
|
|
99
|
+
* @param {string} prompt - e.g. "Create a blog application with comments and tags"
|
|
100
|
+
* @returns {{ domain: string, tables: object }}
|
|
101
|
+
*/
|
|
102
|
+
static parse(prompt) {
|
|
103
|
+
const lower = prompt.toLowerCase();
|
|
104
|
+
let bestMatch = null;
|
|
105
|
+
let bestScore = 0;
|
|
106
|
+
|
|
107
|
+
for (const [pattern, blueprint] of Object.entries(DOMAIN_PATTERNS)) {
|
|
108
|
+
const regex = new RegExp(pattern, 'i');
|
|
109
|
+
const keywords = pattern.split('|').map(k => k.replace(/[^a-z]/g, ''));
|
|
110
|
+
let score = 0;
|
|
111
|
+
|
|
112
|
+
for (const kw of keywords) {
|
|
113
|
+
if (lower.includes(kw)) score++;
|
|
114
|
+
}
|
|
115
|
+
if (regex.test(lower)) score += 2;
|
|
116
|
+
|
|
117
|
+
if (score > bestScore) {
|
|
118
|
+
bestScore = score;
|
|
119
|
+
bestMatch = { pattern, blueprint, score };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Default to generic API/Auth if no match
|
|
124
|
+
if (!bestMatch || bestScore < 1) {
|
|
125
|
+
bestMatch = { pattern: 'api|auth', blueprint: DOMAIN_PATTERNS['api|auth|rest|user'], score: 0 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
domain: bestMatch.pattern.split('|')[0],
|
|
130
|
+
tables: bestMatch.blueprint.tables,
|
|
131
|
+
score: bestMatch.score
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Generate model files from a blueprint.
|
|
137
|
+
* @param {{ tables: object }} blueprint
|
|
138
|
+
* @param {string} outputDir - e.g. process.cwd() + '/models'
|
|
139
|
+
* @returns {string[]} created file paths
|
|
140
|
+
*/
|
|
141
|
+
static generateModels(blueprint, outputDir) {
|
|
142
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
143
|
+
const created = [];
|
|
144
|
+
|
|
145
|
+
for (const [tableName, config] of Object.entries(blueprint.tables)) {
|
|
146
|
+
if (config.pivot) continue; // Skip pivot tables for model files
|
|
147
|
+
|
|
148
|
+
const className = this._toClassName(tableName);
|
|
149
|
+
const fillable = config.columns
|
|
150
|
+
.map(c => c.split(':')[0])
|
|
151
|
+
.filter(c => !['id', 'created_at', 'updated_at'].includes(c));
|
|
152
|
+
|
|
153
|
+
const hidden = fillable.filter(c => c === 'password' || c.includes('token') || c.includes('secret'));
|
|
154
|
+
|
|
155
|
+
const content = `const { Model } = require('outlet-orm');
|
|
156
|
+
|
|
157
|
+
class ${className} extends Model {
|
|
158
|
+
static table = '${tableName}';
|
|
159
|
+
static fillable = ${JSON.stringify(fillable)};${hidden.length > 0 ? `\n static hidden = ${JSON.stringify(hidden)};` : ''}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = ${className};
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
const filePath = path.join(outputDir, `${className}.js`);
|
|
166
|
+
fs.writeFileSync(filePath, content);
|
|
167
|
+
created.push(filePath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return created;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Generate migration files from a blueprint.
|
|
175
|
+
* @param {{ tables: object }} blueprint
|
|
176
|
+
* @param {string} outputDir - e.g. process.cwd() + '/database/migrations'
|
|
177
|
+
* @returns {string[]} created file paths
|
|
178
|
+
*/
|
|
179
|
+
static generateMigrations(blueprint, outputDir) {
|
|
180
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
181
|
+
const created = [];
|
|
182
|
+
let index = 0;
|
|
183
|
+
|
|
184
|
+
for (const [tableName, config] of Object.entries(blueprint.tables)) {
|
|
185
|
+
index++;
|
|
186
|
+
const timestamp = new Date(Date.now() + index * 1000)
|
|
187
|
+
.toISOString()
|
|
188
|
+
.replace(/[-:]/g, '')
|
|
189
|
+
.replace(/T/, '_')
|
|
190
|
+
.replace(/\..+/, '');
|
|
191
|
+
|
|
192
|
+
const className = `Create${this._toClassName(tableName)}Table`;
|
|
193
|
+
const fileName = `${timestamp}_create_${tableName}_table.js`;
|
|
194
|
+
const filePath = path.join(outputDir, fileName);
|
|
195
|
+
|
|
196
|
+
const columnDefs = config.columns.map(c => this._columnToSchema(c)).join('\n ');
|
|
197
|
+
|
|
198
|
+
const content = `const { Migration } = require('outlet-orm');
|
|
199
|
+
|
|
200
|
+
class ${className} extends Migration {
|
|
201
|
+
async up() {
|
|
202
|
+
const schema = this.getSchema();
|
|
203
|
+
await schema.create('${tableName}', (table) => {
|
|
204
|
+
table.id();
|
|
205
|
+
${columnDefs}
|
|
206
|
+
table.timestamps();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async down() {
|
|
211
|
+
const schema = this.getSchema();
|
|
212
|
+
await schema.dropIfExists('${tableName}');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = ${className};
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
fs.writeFileSync(filePath, content);
|
|
220
|
+
created.push(filePath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return created;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generate a seed file from a blueprint.
|
|
228
|
+
* @param {{ tables: object }} blueprint
|
|
229
|
+
* @param {string} outputDir
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
static generateSeeder(blueprint, outputDir) {
|
|
233
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
234
|
+
const tables = Object.keys(blueprint.tables);
|
|
235
|
+
|
|
236
|
+
const seedCalls = tables
|
|
237
|
+
.filter(t => !blueprint.tables[t].pivot)
|
|
238
|
+
.map(t => ` // await this.call('${this._toClassName(t)}Seeder');`)
|
|
239
|
+
.join('\n');
|
|
240
|
+
|
|
241
|
+
const content = `const { Seeder } = require('outlet-orm');
|
|
242
|
+
|
|
243
|
+
class DatabaseSeeder extends Seeder {
|
|
244
|
+
async run() {
|
|
245
|
+
${seedCalls}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = DatabaseSeeder;
|
|
250
|
+
`;
|
|
251
|
+
|
|
252
|
+
const filePath = path.join(outputDir, 'DatabaseSeeder.js');
|
|
253
|
+
fs.writeFileSync(filePath, content);
|
|
254
|
+
return filePath;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Helpers ───────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
static _toClassName(tableName) {
|
|
260
|
+
// users -> User, order_items -> OrderItem
|
|
261
|
+
return tableName
|
|
262
|
+
.split('_')
|
|
263
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
264
|
+
.join('')
|
|
265
|
+
.replace(/s$/, ''); // naive singularize (plural -> singular)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
static _columnToSchema(columnDef) {
|
|
269
|
+
// Format: name:type:modifier1:modifier2
|
|
270
|
+
// e.g. "email:string:unique", "price:decimal(10,2)", "status:string:default(pending)"
|
|
271
|
+
const parts = columnDef.split(':');
|
|
272
|
+
const name = parts[0];
|
|
273
|
+
const type = parts[1] || 'string';
|
|
274
|
+
const modifiers = parts.slice(2);
|
|
275
|
+
|
|
276
|
+
// Parse type and arguments
|
|
277
|
+
const typeMatch = type.match(/^(\w+)(?:\((.+)\))?$/);
|
|
278
|
+
const typeName = typeMatch ? typeMatch[1] : type;
|
|
279
|
+
const typeArgs = typeMatch && typeMatch[2] ? `, ${typeMatch[2]}` : '';
|
|
280
|
+
|
|
281
|
+
// Map to schema builder methods
|
|
282
|
+
const typeMap = {
|
|
283
|
+
string: 'string',
|
|
284
|
+
text: 'text',
|
|
285
|
+
integer: 'integer',
|
|
286
|
+
int: 'integer',
|
|
287
|
+
boolean: 'boolean',
|
|
288
|
+
bool: 'boolean',
|
|
289
|
+
decimal: 'decimal',
|
|
290
|
+
float: 'float',
|
|
291
|
+
date: 'date',
|
|
292
|
+
timestamp: 'timestamp',
|
|
293
|
+
json: 'json',
|
|
294
|
+
foreignId: 'integer'
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const schemaType = typeMap[typeName] || 'string';
|
|
298
|
+
let line = `table.${schemaType}('${name}'${typeArgs})`;
|
|
299
|
+
|
|
300
|
+
// Apply modifiers
|
|
301
|
+
for (const mod of modifiers) {
|
|
302
|
+
if (mod === 'unique') line += '.unique()';
|
|
303
|
+
else if (mod === 'nullable') line += '.nullable()';
|
|
304
|
+
else if (mod.startsWith('default(')) {
|
|
305
|
+
const val = mod.match(/default\((.+)\)/)?.[1] || '';
|
|
306
|
+
// Smart quoting: don't quote numbers or booleans
|
|
307
|
+
const isNum = !isNaN(val);
|
|
308
|
+
const isBool = val === 'true' || val === 'false';
|
|
309
|
+
const quoted = isNum || isBool ? val : `'${val}'`;
|
|
310
|
+
line += `.default(${quoted})`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return line + ';';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = PromptGenerator;
|
package/src/Model.js
CHANGED
|
@@ -665,6 +665,147 @@ class Model {
|
|
|
665
665
|
return this.query().with(...relations);
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
+
// ==================== Convenience Query Methods ====================
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Find the first record matching conditions or create a new one
|
|
672
|
+
* @param {Object} conditions - Where conditions to search
|
|
673
|
+
* @param {Object} [values={}] - Additional attributes for creation
|
|
674
|
+
* @returns {Promise<Model>}
|
|
675
|
+
*/
|
|
676
|
+
static async firstOrCreate(conditions, values = {}) {
|
|
677
|
+
const query = this.query();
|
|
678
|
+
for (const [key, val] of Object.entries(conditions)) {
|
|
679
|
+
query.where(key, val);
|
|
680
|
+
}
|
|
681
|
+
const existing = await query.first();
|
|
682
|
+
if (existing) return existing;
|
|
683
|
+
return this.create({ ...conditions, ...values });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Find the first record matching conditions or return a new (unsaved) instance
|
|
688
|
+
* @param {Object} conditions - Where conditions to search
|
|
689
|
+
* @param {Object} [values={}] - Additional attributes for the new instance
|
|
690
|
+
* @returns {Promise<Model>}
|
|
691
|
+
*/
|
|
692
|
+
static async firstOrNew(conditions, values = {}) {
|
|
693
|
+
const query = this.query();
|
|
694
|
+
for (const [key, val] of Object.entries(conditions)) {
|
|
695
|
+
query.where(key, val);
|
|
696
|
+
}
|
|
697
|
+
const existing = await query.first();
|
|
698
|
+
if (existing) return existing;
|
|
699
|
+
const instance = new this({ ...conditions, ...values });
|
|
700
|
+
return instance;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Find a record matching conditions and update it, or create a new one
|
|
705
|
+
* @param {Object} conditions - Where conditions to search
|
|
706
|
+
* @param {Object} values - Attributes to update or set on creation
|
|
707
|
+
* @returns {Promise<Model>}
|
|
708
|
+
*/
|
|
709
|
+
static async updateOrCreate(conditions, values = {}) {
|
|
710
|
+
const query = this.query();
|
|
711
|
+
for (const [key, val] of Object.entries(conditions)) {
|
|
712
|
+
query.where(key, val);
|
|
713
|
+
}
|
|
714
|
+
const existing = await query.first();
|
|
715
|
+
if (existing) {
|
|
716
|
+
for (const [key, val] of Object.entries(values)) {
|
|
717
|
+
existing.setAttribute(key, val);
|
|
718
|
+
}
|
|
719
|
+
await existing.save();
|
|
720
|
+
return existing;
|
|
721
|
+
}
|
|
722
|
+
return this.create({ ...conditions, ...values });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Insert or update multiple records in bulk.
|
|
727
|
+
* @param {Array<Object>} rows - Array of records to upsert
|
|
728
|
+
* @param {string|string[]} uniqueBy - Column(s) that determine uniqueness
|
|
729
|
+
* @param {string[]} [update] - Columns to update on conflict (default: all non-unique columns)
|
|
730
|
+
* @returns {Promise<any>}
|
|
731
|
+
*/
|
|
732
|
+
static async upsert(rows, uniqueBy, update) {
|
|
733
|
+
if (!rows || rows.length === 0) return;
|
|
734
|
+
this.ensureConnection();
|
|
735
|
+
const uniqueCols = Array.isArray(uniqueBy) ? uniqueBy : [uniqueBy];
|
|
736
|
+
|
|
737
|
+
// Determine columns to update on conflict
|
|
738
|
+
const allCols = Object.keys(rows[0]);
|
|
739
|
+
const updateCols = update || allCols.filter(c => !uniqueCols.includes(c));
|
|
740
|
+
|
|
741
|
+
// Build driver-specific upsert SQL
|
|
742
|
+
const table = this.table;
|
|
743
|
+
const columns = allCols;
|
|
744
|
+
const placeholders = rows.map(() => `(${columns.map(() => '?').join(', ')})`).join(', ');
|
|
745
|
+
const values = rows.flatMap(r => columns.map(c => r[c] !== undefined ? r[c] : null));
|
|
746
|
+
|
|
747
|
+
const conn = this.connection;
|
|
748
|
+
const driver = conn.config ? conn.config.driver : 'mysql';
|
|
749
|
+
|
|
750
|
+
let sql;
|
|
751
|
+
if (driver === 'sqlite') {
|
|
752
|
+
const updateSet = updateCols.map(c => `\`${c}\` = excluded.\`${c}\``).join(', ');
|
|
753
|
+
sql = `INSERT INTO \`${table}\` (${columns.map(c => `\`${c}\``).join(', ')}) VALUES ${placeholders} ON CONFLICT (${uniqueCols.map(c => `\`${c}\``).join(', ')}) DO UPDATE SET ${updateSet}`;
|
|
754
|
+
} else if (driver === 'postgres' || driver === 'postgresql') {
|
|
755
|
+
const updateSet = updateCols.map(c => `"${c}" = EXCLUDED."${c}"`).join(', ');
|
|
756
|
+
sql = `INSERT INTO "${table}" (${columns.map(c => `"${c}"`).join(', ')}) VALUES ${placeholders} ON CONFLICT (${uniqueCols.map(c => `"${c}"`).join(', ')}) DO UPDATE SET ${updateSet}`;
|
|
757
|
+
} else {
|
|
758
|
+
// MySQL: INSERT ... ON DUPLICATE KEY UPDATE
|
|
759
|
+
const updateSet = updateCols.map(c => `\`${c}\` = VALUES(\`${c}\`)`).join(', ');
|
|
760
|
+
sql = `INSERT INTO \`${table}\` (${columns.map(c => `\`${c}\``).join(', ')}) VALUES ${placeholders} ON DUPLICATE KEY UPDATE ${updateSet}`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return conn.execute(sql, values);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ==================== Observer ====================
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Register an observer class that listens to model events.
|
|
770
|
+
* The observer may define methods: creating, created, updating, updated,
|
|
771
|
+
* saving, saved, deleting, deleted, restoring, restored.
|
|
772
|
+
* @param {Object|Function} observer - Observer instance or class
|
|
773
|
+
*/
|
|
774
|
+
static observe(observer) {
|
|
775
|
+
const instance = typeof observer === 'function' ? new observer() : observer;
|
|
776
|
+
const events = [
|
|
777
|
+
'creating', 'created', 'updating', 'updated',
|
|
778
|
+
'saving', 'saved', 'deleting', 'deleted',
|
|
779
|
+
'restoring', 'restored'
|
|
780
|
+
];
|
|
781
|
+
for (const event of events) {
|
|
782
|
+
if (typeof instance[event] === 'function') {
|
|
783
|
+
this.on(event, (model) => instance[event](model));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ==================== Cursor / Stream ====================
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Lazily iterate over all matching records using an async generator.
|
|
792
|
+
* Yields one model instance at a time, consuming minimal memory.
|
|
793
|
+
* @param {number} [chunkSize=100] - Number of records per internal query
|
|
794
|
+
* @returns {AsyncGenerator<Model>}
|
|
795
|
+
*/
|
|
796
|
+
static async *cursor(chunkSize = 100) {
|
|
797
|
+
let offset = 0;
|
|
798
|
+
while (true) {
|
|
799
|
+
const results = await this.query().limit(chunkSize).offset(offset).get();
|
|
800
|
+
if (results.length === 0) break;
|
|
801
|
+
for (const model of results) {
|
|
802
|
+
yield model;
|
|
803
|
+
}
|
|
804
|
+
if (results.length < chunkSize) break;
|
|
805
|
+
offset += chunkSize;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
668
809
|
/**
|
|
669
810
|
* Include hidden attributes in query results
|
|
670
811
|
* @returns {QueryBuilder}
|
|
@@ -704,18 +845,24 @@ class Model {
|
|
|
704
845
|
}
|
|
705
846
|
|
|
706
847
|
/**
|
|
707
|
-
* Set an attribute
|
|
848
|
+
* Set an attribute (runs mutator if defined)
|
|
708
849
|
* @param {string} key
|
|
709
850
|
* @param {any} value
|
|
710
851
|
* @returns {this}
|
|
711
852
|
*/
|
|
712
853
|
setAttribute(key, value) {
|
|
854
|
+
// Check for mutator: set{Key}Attribute
|
|
855
|
+
const mutator = `set${key.charAt(0).toUpperCase()}${key.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase())}Attribute`;
|
|
856
|
+
if (typeof this[mutator] === 'function') {
|
|
857
|
+
this[mutator](value);
|
|
858
|
+
return this;
|
|
859
|
+
}
|
|
713
860
|
this.attributes[key] = this.castAttribute(key, value);
|
|
714
861
|
return this;
|
|
715
862
|
}
|
|
716
863
|
|
|
717
864
|
/**
|
|
718
|
-
* Get an attribute
|
|
865
|
+
* Get an attribute (runs accessor if defined)
|
|
719
866
|
* @param {string} key
|
|
720
867
|
* @returns {any}
|
|
721
868
|
*/
|
|
@@ -723,6 +870,11 @@ class Model {
|
|
|
723
870
|
if (this.relations[key]) {
|
|
724
871
|
return this.relations[key];
|
|
725
872
|
}
|
|
873
|
+
// Check for accessor: get{Key}Attribute
|
|
874
|
+
const accessor = `get${key.charAt(0).toUpperCase()}${key.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase())}Attribute`;
|
|
875
|
+
if (typeof this[accessor] === 'function') {
|
|
876
|
+
return this[accessor](this.attributes[key]);
|
|
877
|
+
}
|
|
726
878
|
return this.castAttribute(key, this.attributes[key]);
|
|
727
879
|
}
|
|
728
880
|
|
package/src/QueryBuilder.js
CHANGED
|
@@ -623,6 +623,88 @@ class QueryBuilder {
|
|
|
623
623
|
return result;
|
|
624
624
|
}
|
|
625
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Get the first record matching current wheres or create a new one
|
|
628
|
+
* @param {Object} [values={}] - Additional attributes to merge on creation
|
|
629
|
+
* @returns {Promise<Model>}
|
|
630
|
+
*/
|
|
631
|
+
async firstOrCreate(values = {}) {
|
|
632
|
+
const existing = await this.first();
|
|
633
|
+
if (existing) return existing;
|
|
634
|
+
// Build conditions from current wheres
|
|
635
|
+
const conditions = {};
|
|
636
|
+
for (const w of this.wheres) {
|
|
637
|
+
if (w.type === 'basic' && w.operator === '=') {
|
|
638
|
+
conditions[w.column] = w.value;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const instance = new this.model({ ...conditions, ...values });
|
|
642
|
+
return instance.save();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Get the first record matching current wheres or return a new (unsaved) instance
|
|
647
|
+
* @param {Object} [values={}] - Additional attributes for the instance
|
|
648
|
+
* @returns {Promise<Model>}
|
|
649
|
+
*/
|
|
650
|
+
async firstOrNew(values = {}) {
|
|
651
|
+
const existing = await this.first();
|
|
652
|
+
if (existing) return existing;
|
|
653
|
+
const conditions = {};
|
|
654
|
+
for (const w of this.wheres) {
|
|
655
|
+
if (w.type === 'basic' && w.operator === '=') {
|
|
656
|
+
conditions[w.column] = w.value;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return new this.model({ ...conditions, ...values });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Find a record matching current wheres and update it, or create a new one
|
|
664
|
+
* @param {Object} values - Attributes to update or set on creation
|
|
665
|
+
* @returns {Promise<Model>}
|
|
666
|
+
*/
|
|
667
|
+
async updateOrCreate(values = {}) {
|
|
668
|
+
const existing = await this.first();
|
|
669
|
+
if (existing) {
|
|
670
|
+
for (const [key, val] of Object.entries(values)) {
|
|
671
|
+
existing.setAttribute(key, val);
|
|
672
|
+
}
|
|
673
|
+
await existing.save();
|
|
674
|
+
return existing;
|
|
675
|
+
}
|
|
676
|
+
const conditions = {};
|
|
677
|
+
for (const w of this.wheres) {
|
|
678
|
+
if (w.type === 'basic' && w.operator === '=') {
|
|
679
|
+
conditions[w.column] = w.value;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const instance = new this.model({ ...conditions, ...values });
|
|
683
|
+
return instance.save();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Lazily iterate over matching records using an async generator.
|
|
688
|
+
* Yields one model instance at a time, consuming minimal memory.
|
|
689
|
+
* @param {number} [chunkSize=100] - Number of records per internal query
|
|
690
|
+
* @returns {AsyncGenerator<Model>}
|
|
691
|
+
*/
|
|
692
|
+
async *cursor(chunkSize = 100) {
|
|
693
|
+
let offset = 0;
|
|
694
|
+
while (true) {
|
|
695
|
+
const cloned = this.clone();
|
|
696
|
+
cloned.limitValue = chunkSize;
|
|
697
|
+
cloned.offsetValue = offset;
|
|
698
|
+
const results = await cloned.get();
|
|
699
|
+
if (results.length === 0) break;
|
|
700
|
+
for (const model of results) {
|
|
701
|
+
yield model;
|
|
702
|
+
}
|
|
703
|
+
if (results.length < chunkSize) break;
|
|
704
|
+
offset += chunkSize;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
626
708
|
/**
|
|
627
709
|
* Paginate the results
|
|
628
710
|
* @param {number} page
|
package/src/index.js
CHANGED
|
@@ -28,6 +28,11 @@ const BackupEncryption = require('./Backup/BackupEncryption');
|
|
|
28
28
|
const BackupSocketServer = require('./Backup/BackupSocketServer');
|
|
29
29
|
const BackupSocketClient = require('./Backup/BackupSocketClient');
|
|
30
30
|
|
|
31
|
+
// AI (v7.0.0)
|
|
32
|
+
const MCPServer = require('./AI/MCPServer');
|
|
33
|
+
const AISafetyGuardrails = require('./AI/AISafetyGuardrails');
|
|
34
|
+
const PromptGenerator = require('./AI/PromptGenerator');
|
|
35
|
+
|
|
31
36
|
module.exports = {
|
|
32
37
|
// Core
|
|
33
38
|
Model,
|
|
@@ -65,5 +70,10 @@ module.exports = {
|
|
|
65
70
|
BackupScheduler,
|
|
66
71
|
BackupEncryption,
|
|
67
72
|
BackupSocketServer,
|
|
68
|
-
BackupSocketClient
|
|
73
|
+
BackupSocketClient,
|
|
74
|
+
|
|
75
|
+
// AI (v7.0.0)
|
|
76
|
+
MCPServer,
|
|
77
|
+
AISafetyGuardrails,
|
|
78
|
+
PromptGenerator
|
|
69
79
|
};
|