@classytic/arc 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 +900 -0
- package/bin/arc.js +344 -0
- package/dist/adapters/index.d.ts +237 -0
- package/dist/adapters/index.js +668 -0
- package/dist/arcCorePlugin-DTPWXcZN.d.ts +273 -0
- package/dist/audit/index.d.ts +195 -0
- package/dist/audit/index.js +319 -0
- package/dist/auth/index.d.ts +47 -0
- package/dist/auth/index.js +174 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +474 -0
- package/dist/cli/commands/introspect.d.ts +8 -0
- package/dist/cli/commands/introspect.js +338 -0
- package/dist/cli/index.d.ts +43 -0
- package/dist/cli/index.js +520 -0
- package/dist/createApp-pzUAkzbz.d.ts +77 -0
- package/dist/docs/index.d.ts +166 -0
- package/dist/docs/index.js +650 -0
- package/dist/errors-8WIxGS_6.d.ts +122 -0
- package/dist/events/index.d.ts +117 -0
- package/dist/events/index.js +89 -0
- package/dist/factory/index.d.ts +38 -0
- package/dist/factory/index.js +1664 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +199 -0
- package/dist/idempotency/index.d.ts +323 -0
- package/dist/idempotency/index.js +500 -0
- package/dist/index-DkAW8BXh.d.ts +1302 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +4734 -0
- package/dist/migrations/index.d.ts +185 -0
- package/dist/migrations/index.js +274 -0
- package/dist/org/index.d.ts +129 -0
- package/dist/org/index.js +220 -0
- package/dist/permissions/index.d.ts +144 -0
- package/dist/permissions/index.js +100 -0
- package/dist/plugins/index.d.ts +46 -0
- package/dist/plugins/index.js +1069 -0
- package/dist/policies/index.d.ts +398 -0
- package/dist/policies/index.js +196 -0
- package/dist/presets/index.d.ts +336 -0
- package/dist/presets/index.js +382 -0
- package/dist/presets/multiTenant.d.ts +39 -0
- package/dist/presets/multiTenant.js +112 -0
- package/dist/registry/index.d.ts +16 -0
- package/dist/registry/index.js +253 -0
- package/dist/testing/index.d.ts +618 -0
- package/dist/testing/index.js +48032 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.js +8 -0
- package/dist/types-0IPhH_NR.d.ts +143 -0
- package/dist/types-B99TBmFV.d.ts +76 -0
- package/dist/utils/index.d.ts +655 -0
- package/dist/utils/index.js +905 -0
- package/package.json +227 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
// src/cli/index.ts
|
|
5
|
+
async function generate(type, name, options = {}) {
|
|
6
|
+
const validTypes = ["resource", "controller", "model"];
|
|
7
|
+
if (!validTypes.includes(type)) {
|
|
8
|
+
throw new Error(`Unknown generation type: ${type}. Valid types: ${validTypes.join(", ")}`);
|
|
9
|
+
}
|
|
10
|
+
const {
|
|
11
|
+
module: moduleName,
|
|
12
|
+
presets = [],
|
|
13
|
+
parentField = "parent",
|
|
14
|
+
withTests = true,
|
|
15
|
+
dryRun = false,
|
|
16
|
+
force = false,
|
|
17
|
+
outputDir = process.cwd(),
|
|
18
|
+
typescript = true
|
|
19
|
+
} = options;
|
|
20
|
+
const SAFE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
21
|
+
if (!SAFE_NAME_PATTERN.test(name)) {
|
|
22
|
+
throw new Error(`Invalid resource name: "${name}". Use only letters, numbers, and hyphens (must start with letter).`);
|
|
23
|
+
}
|
|
24
|
+
if (moduleName && !SAFE_NAME_PATTERN.test(moduleName)) {
|
|
25
|
+
throw new Error(`Invalid module name: "${moduleName}". Use only letters, numbers, and hyphens (must start with letter).`);
|
|
26
|
+
}
|
|
27
|
+
const ext = typescript ? "ts" : "js";
|
|
28
|
+
const kebab = kebabCase(name);
|
|
29
|
+
const dirPath = moduleName ? path.join(outputDir, "modules", moduleName, kebab) : path.join(outputDir, "modules", kebab);
|
|
30
|
+
let files;
|
|
31
|
+
switch (type) {
|
|
32
|
+
case "resource":
|
|
33
|
+
files = generateResourceFiles(name, { presets, parentField, module: moduleName, withTests, typescript });
|
|
34
|
+
break;
|
|
35
|
+
case "controller":
|
|
36
|
+
files = [{ name: `${kebab}.controller.${ext}`, content: controllerTemplate(name, { presets, typescript }) }];
|
|
37
|
+
break;
|
|
38
|
+
case "model":
|
|
39
|
+
files = [{ name: `${kebab}.model.${ext}`, content: modelTemplate(name, { presets, parentField, typescript }) }];
|
|
40
|
+
break;
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`Unknown type: ${type}`);
|
|
43
|
+
}
|
|
44
|
+
console.log(`
|
|
45
|
+
š¦ Generating ${type}: ${pascalCase(name)}`);
|
|
46
|
+
console.log(`š Directory: ${dirPath}`);
|
|
47
|
+
console.log(`š§ Presets: ${presets.length ? presets.join(", ") : "none"}`);
|
|
48
|
+
console.log(`š Language: ${typescript ? "TypeScript" : "JavaScript"}`);
|
|
49
|
+
if (dryRun) {
|
|
50
|
+
console.log("\nš DRY RUN - No files created\n");
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
console.log(` Would create: ${file.name}`);
|
|
53
|
+
}
|
|
54
|
+
return { files, dirPath };
|
|
55
|
+
}
|
|
56
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
57
|
+
let created = 0;
|
|
58
|
+
let skipped = 0;
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const filePath = path.join(dirPath, file.name);
|
|
61
|
+
try {
|
|
62
|
+
await fs.access(filePath);
|
|
63
|
+
if (!force) {
|
|
64
|
+
console.log(` āļø Skipped: ${file.name} (exists)`);
|
|
65
|
+
skipped++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
await fs.writeFile(filePath, file.content);
|
|
71
|
+
console.log(` ā
Created: ${file.name}`);
|
|
72
|
+
created++;
|
|
73
|
+
}
|
|
74
|
+
console.log(`
|
|
75
|
+
š Done! Created ${created} file(s), skipped ${skipped}
|
|
76
|
+
`);
|
|
77
|
+
if (type === "resource") {
|
|
78
|
+
printNextSteps(name, moduleName);
|
|
79
|
+
}
|
|
80
|
+
return { files, dirPath };
|
|
81
|
+
}
|
|
82
|
+
function generateResourceFiles(name, options) {
|
|
83
|
+
const { presets, parentField, module: moduleName, withTests, typescript } = options;
|
|
84
|
+
const kebab = kebabCase(name);
|
|
85
|
+
const ext = typescript ? "ts" : "js";
|
|
86
|
+
const files = [
|
|
87
|
+
{ name: `${kebab}.model.${ext}`, content: modelTemplate(name, { presets, parentField, typescript }) },
|
|
88
|
+
{ name: `${kebab}.repository.${ext}`, content: repositoryTemplate(name, { presets, parentField, typescript }) },
|
|
89
|
+
{ name: `${kebab}.controller.${ext}`, content: controllerTemplate(name, { presets, typescript }) },
|
|
90
|
+
{ name: `${kebab}.resource.${ext}`, content: resourceTemplate(name, { presets, parentField, module: moduleName}) },
|
|
91
|
+
{ name: `routes.${ext}`, content: routesTemplate(name) }
|
|
92
|
+
];
|
|
93
|
+
if (withTests) {
|
|
94
|
+
files.push({ name: `${kebab}.test.${ext}`, content: testTemplate(name, { presets, typescript }) });
|
|
95
|
+
}
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
function modelTemplate(name, opts) {
|
|
99
|
+
const pascal = pascalCase(name);
|
|
100
|
+
const camel = camelCase(name);
|
|
101
|
+
const { presets = [], parentField = "parent", typescript = true } = opts;
|
|
102
|
+
const hasSlug = presets.includes("slugLookup");
|
|
103
|
+
const hasSoftDelete = presets.includes("softDelete");
|
|
104
|
+
const hasTree = presets.includes("tree");
|
|
105
|
+
const hasMultiTenant = presets.includes("multiTenant");
|
|
106
|
+
const hasOwned = presets.includes("ownedByUser");
|
|
107
|
+
const hasAudited = presets.includes("audited");
|
|
108
|
+
return `/**
|
|
109
|
+
* ${pascal} Model
|
|
110
|
+
* @generated by Arc CLI
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
import mongoose from 'mongoose';
|
|
114
|
+
${hasSlug ? "import slugPlugin from '@classytic/mongoose-slug-plugin';\n" : ""}
|
|
115
|
+
const ${camel}Schema = new mongoose.Schema(
|
|
116
|
+
{
|
|
117
|
+
name: { type: String, required: true, trim: true },
|
|
118
|
+
${hasSlug ? " slug: { type: String, unique: true, sparse: true, index: true },\n" : ""}${hasTree ? ` ${parentField}: { type: mongoose.Schema.Types.ObjectId, ref: '${pascal}', default: null, index: true },
|
|
119
|
+
displayOrder: { type: Number, default: 0 },
|
|
120
|
+
` : ""}${hasMultiTenant ? " organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },\n" : ""}${hasOwned ? " createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },\n" : ""} description: { type: String, trim: true },
|
|
121
|
+
isActive: { type: Boolean, default: true, index: true },
|
|
122
|
+
${hasSoftDelete ? " deletedAt: { type: Date, default: null, index: true },\n" : ""}${hasAudited ? ` lastModifiedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
|
123
|
+
` : ""} },
|
|
124
|
+
{
|
|
125
|
+
timestamps: true,
|
|
126
|
+
toJSON: { virtuals: true },
|
|
127
|
+
toObject: { virtuals: true },
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Indexes
|
|
132
|
+
${camel}Schema.index({ name: 1 });
|
|
133
|
+
${hasSoftDelete ? `${camel}Schema.index({ deletedAt: 1, isActive: 1 });
|
|
134
|
+
` : ""}${hasSlug ? `
|
|
135
|
+
${camel}Schema.plugin(slugPlugin, { sourceField: 'name' });
|
|
136
|
+
` : ""}
|
|
137
|
+
${typescript ? `export type ${pascal}Document = mongoose.InferSchemaType<typeof ${camel}Schema>;
|
|
138
|
+
` : ""}
|
|
139
|
+
export const ${pascal} = mongoose.model${typescript ? `<${pascal}Document>` : ""}('${pascal}', ${camel}Schema);
|
|
140
|
+
export default ${pascal};
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
function repositoryTemplate(name, opts) {
|
|
144
|
+
const pascal = pascalCase(name);
|
|
145
|
+
const camel = camelCase(name);
|
|
146
|
+
const kebab = kebabCase(name);
|
|
147
|
+
const { presets = [], parentField = "parent", typescript = true } = opts;
|
|
148
|
+
const hasSoftDelete = presets.includes("softDelete");
|
|
149
|
+
const hasSlug = presets.includes("slugLookup");
|
|
150
|
+
const hasTree = presets.includes("tree");
|
|
151
|
+
const typeImport = typescript ? `
|
|
152
|
+
import type { ${pascal}Document } from './${kebab}.model.js';` : "";
|
|
153
|
+
const repoGeneric = typescript ? `<${pascal}Document>` : "";
|
|
154
|
+
const returnType = (t) => typescript ? `: Promise<${t}>` : "";
|
|
155
|
+
return `/**
|
|
156
|
+
* ${pascal} Repository
|
|
157
|
+
* @generated by Arc CLI
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
import { Repository${hasSoftDelete ? ", softDeletePlugin" : ""} } from '@classytic/mongokit';
|
|
161
|
+
import { ${pascal} } from './${kebab}.model.js';${typeImport}
|
|
162
|
+
|
|
163
|
+
class ${pascal}Repository extends Repository${repoGeneric} {
|
|
164
|
+
constructor() {
|
|
165
|
+
super(${pascal}${hasSoftDelete ? ", [softDeletePlugin()]" : ""});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Find all active records */
|
|
169
|
+
async findActive()${returnType(`${pascal}Document[]`)} {
|
|
170
|
+
return this.Model.find({ isActive: true${hasSoftDelete ? ", deletedAt: null" : ""} }).lean();
|
|
171
|
+
}
|
|
172
|
+
${hasSlug ? `
|
|
173
|
+
/** Find by slug */
|
|
174
|
+
async getBySlug(slug${typescript ? ": string" : ""})${returnType(`${pascal}Document | null`)} {
|
|
175
|
+
return this.Model.findOne({ slug: slug.toLowerCase()${hasSoftDelete ? ", deletedAt: null" : ""} }).lean();
|
|
176
|
+
}
|
|
177
|
+
` : ""}${hasSoftDelete ? `
|
|
178
|
+
/** Get soft-deleted records */
|
|
179
|
+
async getDeleted()${returnType(`${pascal}Document[]`)} {
|
|
180
|
+
return this.Model.find({ deletedAt: { $ne: null } }).sort({ deletedAt: -1 }).lean();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Restore a soft-deleted record */
|
|
184
|
+
async restore(id${typescript ? ": string" : ""})${returnType(`${pascal}Document | null`)} {
|
|
185
|
+
return this.Model.findByIdAndUpdate(id, { deletedAt: null }, { new: true }).lean();
|
|
186
|
+
}
|
|
187
|
+
` : ""}${hasTree ? `
|
|
188
|
+
/** Get hierarchical tree structure */
|
|
189
|
+
async getTree()${returnType(`${pascal}Document[]`)} {
|
|
190
|
+
const all = await this.Model.find({ isActive: true${hasSoftDelete ? ", deletedAt: null" : ""} })
|
|
191
|
+
.sort({ displayOrder: 1 })
|
|
192
|
+
.lean();
|
|
193
|
+
|
|
194
|
+
const map = new Map${typescript ? `<string, ${pascal}Document & { children: ${pascal}Document[] }>` : ""}();
|
|
195
|
+
const roots${typescript ? `: (${pascal}Document & { children: ${pascal}Document[] })[]` : ""} = [];
|
|
196
|
+
|
|
197
|
+
for (const item of all) {
|
|
198
|
+
map.set(item._id.toString(), { ...item, children: [] });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const item of all) {
|
|
202
|
+
const node = map.get(item._id.toString())!;
|
|
203
|
+
const parentId = (item${typescript ? " as any" : ""}).${parentField};
|
|
204
|
+
if (parentId && map.has(parentId.toString())) {
|
|
205
|
+
map.get(parentId.toString())!.children.push(node);
|
|
206
|
+
} else {
|
|
207
|
+
roots.push(node);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return roots;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Get direct children of a parent */
|
|
215
|
+
async getChildren(parentId${typescript ? ": string" : ""})${returnType(`${pascal}Document[]`)} {
|
|
216
|
+
return this.Model.find({
|
|
217
|
+
${parentField}: parentId,
|
|
218
|
+
isActive: true${hasSoftDelete ? ",\n deletedAt: null" : ""},
|
|
219
|
+
}).sort({ displayOrder: 1 }).lean();
|
|
220
|
+
}
|
|
221
|
+
` : ""}}
|
|
222
|
+
|
|
223
|
+
export const ${camel}Repository = new ${pascal}Repository();
|
|
224
|
+
export default ${camel}Repository;
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
function controllerTemplate(name, opts) {
|
|
228
|
+
const pascal = pascalCase(name);
|
|
229
|
+
const camel = camelCase(name);
|
|
230
|
+
const kebab = kebabCase(name);
|
|
231
|
+
const { presets = [], typescript = true } = opts;
|
|
232
|
+
const hasSoftDelete = presets.includes("softDelete");
|
|
233
|
+
const hasSlug = presets.includes("slugLookup");
|
|
234
|
+
const hasTree = presets.includes("tree");
|
|
235
|
+
return `/**
|
|
236
|
+
* ${pascal} Controller
|
|
237
|
+
* @generated by Arc CLI
|
|
238
|
+
*
|
|
239
|
+
* Extends BaseController for built-in security:
|
|
240
|
+
* - Organization scoping (multi-tenant isolation)
|
|
241
|
+
* - Ownership checks (user data protection)
|
|
242
|
+
* - Policy-based filtering
|
|
243
|
+
*/
|
|
244
|
+
|
|
245
|
+
import { BaseController } from '@classytic/arc';
|
|
246
|
+
${typescript ? "import type { IRequestContext, IControllerResponse } from '@classytic/arc';\n" : ""}import { ${camel}Repository } from './${kebab}.repository.js';
|
|
247
|
+
|
|
248
|
+
class ${pascal}Controller extends BaseController {
|
|
249
|
+
constructor() {
|
|
250
|
+
super(${camel}Repository);
|
|
251
|
+
|
|
252
|
+
// Bind methods (required for route handler context)
|
|
253
|
+
${hasSlug ? " this.getBySlug = this.getBySlug.bind(this);\n" : ""}${hasSoftDelete ? ` this.getDeleted = this.getDeleted.bind(this);
|
|
254
|
+
this.restore = this.restore.bind(this);
|
|
255
|
+
` : ""}${hasTree ? ` this.getTree = this.getTree.bind(this);
|
|
256
|
+
this.getChildren = this.getChildren.bind(this);
|
|
257
|
+
` : ""} }
|
|
258
|
+
|
|
259
|
+
// ========================================
|
|
260
|
+
// Custom Methods (add your own below)
|
|
261
|
+
// ========================================
|
|
262
|
+
|
|
263
|
+
// Example: Custom search endpoint
|
|
264
|
+
// async search(ctx${typescript ? ": IRequestContext" : ""})${typescript ? ": Promise<IControllerResponse>" : ""} {
|
|
265
|
+
// const { query } = ctx.query${typescript ? " as { query: string }" : ""};
|
|
266
|
+
// const results = await this.repository.Model.find({
|
|
267
|
+
// name: { $regex: query, $options: 'i' },
|
|
268
|
+
// }).lean();
|
|
269
|
+
// return { success: true, data: results, status: 200 };
|
|
270
|
+
// }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const ${camel}Controller = new ${pascal}Controller();
|
|
274
|
+
export default ${camel}Controller;
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
function resourceTemplate(name, opts) {
|
|
278
|
+
const pascal = pascalCase(name);
|
|
279
|
+
const camel = camelCase(name);
|
|
280
|
+
const kebab = kebabCase(name);
|
|
281
|
+
const { presets = [], parentField = "parent", module: moduleName} = opts;
|
|
282
|
+
const presetsStr = presets.length > 0 ? presets.map((p) => {
|
|
283
|
+
if (p === "tree" && parentField !== "parent") {
|
|
284
|
+
return `{ name: 'tree', parentField: '${parentField}' }`;
|
|
285
|
+
}
|
|
286
|
+
return `'${p}'`;
|
|
287
|
+
}).join(",\n ") : "";
|
|
288
|
+
return `/**
|
|
289
|
+
* ${pascal} Resource Definition
|
|
290
|
+
* @generated by Arc CLI
|
|
291
|
+
*/
|
|
292
|
+
|
|
293
|
+
import { defineResource, createMongooseAdapter } from '@classytic/arc';
|
|
294
|
+
import { ${pascal} } from './${kebab}.model.js';
|
|
295
|
+
import { ${camel}Repository } from './${kebab}.repository.js';
|
|
296
|
+
|
|
297
|
+
export default defineResource({
|
|
298
|
+
name: '${kebab}',
|
|
299
|
+
displayName: '${pascal}s',
|
|
300
|
+
${moduleName ? ` module: '${moduleName}',
|
|
301
|
+
` : ""}
|
|
302
|
+
adapter: createMongooseAdapter({
|
|
303
|
+
model: ${pascal},
|
|
304
|
+
repository: ${camel}Repository,
|
|
305
|
+
}),
|
|
306
|
+
${presetsStr ? `
|
|
307
|
+
presets: [
|
|
308
|
+
${presetsStr},
|
|
309
|
+
],
|
|
310
|
+
` : ""}
|
|
311
|
+
permissions: {
|
|
312
|
+
list: [],
|
|
313
|
+
get: [],
|
|
314
|
+
create: ['admin'],
|
|
315
|
+
update: ['admin'],
|
|
316
|
+
delete: ['admin'],
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
additionalRoutes: [],
|
|
320
|
+
|
|
321
|
+
events: {
|
|
322
|
+
created: { description: '${pascal} created' },
|
|
323
|
+
updated: { description: '${pascal} updated' },
|
|
324
|
+
deleted: { description: '${pascal} deleted' },
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
function routesTemplate(name, opts) {
|
|
330
|
+
const camel = camelCase(name);
|
|
331
|
+
const kebab = kebabCase(name);
|
|
332
|
+
return `/**
|
|
333
|
+
* ${pascalCase(name)} Routes
|
|
334
|
+
* @generated by Arc CLI
|
|
335
|
+
*
|
|
336
|
+
* Register this plugin in your app:
|
|
337
|
+
* await fastify.register(${camel}Routes);
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
import ${camel}Resource from './${kebab}.resource.js';
|
|
341
|
+
|
|
342
|
+
export default ${camel}Resource.toPlugin();
|
|
343
|
+
`;
|
|
344
|
+
}
|
|
345
|
+
function testTemplate(name, opts) {
|
|
346
|
+
const pascal = pascalCase(name);
|
|
347
|
+
const kebab = kebabCase(name);
|
|
348
|
+
const { presets = [], typescript = true } = opts;
|
|
349
|
+
const hasSoftDelete = presets.includes("softDelete");
|
|
350
|
+
const hasSlug = presets.includes("slugLookup");
|
|
351
|
+
return `/**
|
|
352
|
+
* ${pascal} Tests
|
|
353
|
+
* @generated by Arc CLI
|
|
354
|
+
*/
|
|
355
|
+
|
|
356
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
357
|
+
${typescript ? "import type { FastifyInstance } from 'fastify';\n" : ""}import { createTestApp } from '@classytic/arc/testing';
|
|
358
|
+
|
|
359
|
+
describe('${pascal} API', () => {
|
|
360
|
+
let app${typescript ? ": FastifyInstance" : ""};
|
|
361
|
+
|
|
362
|
+
beforeAll(async () => {
|
|
363
|
+
app = await createTestApp({
|
|
364
|
+
auth: { jwt: { secret: 'test-secret-32-chars-minimum-len' } },
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
afterAll(async () => {
|
|
369
|
+
await app?.close();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('CRUD Operations', () => {
|
|
373
|
+
let createdId${typescript ? ": string" : ""};
|
|
374
|
+
|
|
375
|
+
it('should create a ${kebab}', async () => {
|
|
376
|
+
const response = await app.inject({
|
|
377
|
+
method: 'POST',
|
|
378
|
+
url: '/${kebab}s',
|
|
379
|
+
payload: { name: 'Test ${pascal}' },
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(response.statusCode).toBe(201);
|
|
383
|
+
const body = response.json();
|
|
384
|
+
expect(body.success).toBe(true);
|
|
385
|
+
expect(body.data.name).toBe('Test ${pascal}');
|
|
386
|
+
createdId = body.data._id;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should list ${kebab}s', async () => {
|
|
390
|
+
const response = await app.inject({
|
|
391
|
+
method: 'GET',
|
|
392
|
+
url: '/${kebab}s',
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(response.statusCode).toBe(200);
|
|
396
|
+
const body = response.json();
|
|
397
|
+
expect(body.success).toBe(true);
|
|
398
|
+
expect(Array.isArray(body.docs || body.data)).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should get ${kebab} by id', async () => {
|
|
402
|
+
const response = await app.inject({
|
|
403
|
+
method: 'GET',
|
|
404
|
+
url: \`/${kebab}s/\${createdId}\`,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(response.statusCode).toBe(200);
|
|
408
|
+
expect(response.json().data._id).toBe(createdId);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should update ${kebab}', async () => {
|
|
412
|
+
const response = await app.inject({
|
|
413
|
+
method: 'PATCH',
|
|
414
|
+
url: \`/${kebab}s/\${createdId}\`,
|
|
415
|
+
payload: { name: 'Updated ${pascal}' },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(response.statusCode).toBe(200);
|
|
419
|
+
expect(response.json().data.name).toBe('Updated ${pascal}');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should delete ${kebab}', async () => {
|
|
423
|
+
const response = await app.inject({
|
|
424
|
+
method: 'DELETE',
|
|
425
|
+
url: \`/${kebab}s/\${createdId}\`,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(response.statusCode).toBe(200);
|
|
429
|
+
expect(response.json().success).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
${hasSlug ? `
|
|
433
|
+
describe('Slug Lookup', () => {
|
|
434
|
+
it('should get by slug', async () => {
|
|
435
|
+
// Create with auto-generated slug
|
|
436
|
+
const createRes = await app.inject({
|
|
437
|
+
method: 'POST',
|
|
438
|
+
url: '/${kebab}s',
|
|
439
|
+
payload: { name: 'Slug Test Item' },
|
|
440
|
+
});
|
|
441
|
+
const slug = createRes.json().data.slug;
|
|
442
|
+
|
|
443
|
+
const response = await app.inject({
|
|
444
|
+
method: 'GET',
|
|
445
|
+
url: \`/${kebab}s/slug/\${slug}\`,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(response.statusCode).toBe(200);
|
|
449
|
+
expect(response.json().data.slug).toBe(slug);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
` : ""}${hasSoftDelete ? `
|
|
453
|
+
describe('Soft Delete', () => {
|
|
454
|
+
it('should soft delete and restore', async () => {
|
|
455
|
+
// Create
|
|
456
|
+
const createRes = await app.inject({
|
|
457
|
+
method: 'POST',
|
|
458
|
+
url: '/${kebab}s',
|
|
459
|
+
payload: { name: 'Soft Delete Test' },
|
|
460
|
+
});
|
|
461
|
+
const id = createRes.json().data._id;
|
|
462
|
+
|
|
463
|
+
// Delete (soft)
|
|
464
|
+
await app.inject({
|
|
465
|
+
method: 'DELETE',
|
|
466
|
+
url: \`/${kebab}s/\${id}\`,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Verify in deleted list
|
|
470
|
+
const deletedRes = await app.inject({
|
|
471
|
+
method: 'GET',
|
|
472
|
+
url: '/${kebab}s/deleted',
|
|
473
|
+
});
|
|
474
|
+
expect(deletedRes.json().data.some((d${typescript ? ": any" : ""}) => d._id === id)).toBe(true);
|
|
475
|
+
|
|
476
|
+
// Restore
|
|
477
|
+
const restoreRes = await app.inject({
|
|
478
|
+
method: 'POST',
|
|
479
|
+
url: \`/${kebab}s/\${id}/restore\`,
|
|
480
|
+
});
|
|
481
|
+
expect(restoreRes.statusCode).toBe(200);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
` : ""}});
|
|
485
|
+
`;
|
|
486
|
+
}
|
|
487
|
+
function printNextSteps(name, moduleName) {
|
|
488
|
+
const kebab = kebabCase(name);
|
|
489
|
+
const camel = camelCase(name);
|
|
490
|
+
const modulePath = moduleName ? `${moduleName}/${kebab}` : kebab;
|
|
491
|
+
console.log(`š Next Steps:
|
|
492
|
+
|
|
493
|
+
1. Register the route in your app:
|
|
494
|
+
${`import ${camel}Routes from '#modules/${modulePath}/routes.js';
|
|
495
|
+
await fastify.register(${camel}Routes);`}
|
|
496
|
+
|
|
497
|
+
2. Run tests:
|
|
498
|
+
npm test -- ${kebab}
|
|
499
|
+
|
|
500
|
+
3. Access your API:
|
|
501
|
+
GET /${kebab}s List all
|
|
502
|
+
GET /${kebab}s/:id Get by ID
|
|
503
|
+
POST /${kebab}s Create
|
|
504
|
+
PATCH /${kebab}s/:id Update
|
|
505
|
+
DELETE /${kebab}s/:id Delete
|
|
506
|
+
`);
|
|
507
|
+
}
|
|
508
|
+
function pascalCase(str) {
|
|
509
|
+
return str.split(/[-_\s]+/).map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join("");
|
|
510
|
+
}
|
|
511
|
+
function camelCase(str) {
|
|
512
|
+
const pascal = pascalCase(str);
|
|
513
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
514
|
+
}
|
|
515
|
+
function kebabCase(str) {
|
|
516
|
+
return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
517
|
+
}
|
|
518
|
+
var cli_default = { generate };
|
|
519
|
+
|
|
520
|
+
export { cli_default as default, generate };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { FastifyInstance } from 'fastify';
|
|
2
|
+
import { C as CreateAppOptions } from './types-0IPhH_NR.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ArcFactory - Production-ready Fastify application factory
|
|
6
|
+
*
|
|
7
|
+
* Enforces security best practices by making plugins opt-out instead of opt-in.
|
|
8
|
+
* A developer must explicitly disable security features rather than forget to enable them.
|
|
9
|
+
*
|
|
10
|
+
* Note: Arc is database-agnostic. Connect your database separately and provide
|
|
11
|
+
* adapters when defining resources. This allows multiple databases, custom
|
|
12
|
+
* connection pooling, and full control over your data layer.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // 1. Connect your database(s) separately
|
|
16
|
+
* import mongoose from 'mongoose';
|
|
17
|
+
* await mongoose.connect(process.env.MONGO_URI);
|
|
18
|
+
*
|
|
19
|
+
* // 2. Create Arc app (no database config needed)
|
|
20
|
+
* const app = await createApp({
|
|
21
|
+
* preset: 'production',
|
|
22
|
+
* auth: { jwt: { secret: process.env.JWT_SECRET } },
|
|
23
|
+
* cors: { origin: ['https://example.com'] },
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // 3. Register resources with your adapters
|
|
27
|
+
* await app.register(productResource.toPlugin());
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // Multiple databases example
|
|
31
|
+
* const primaryDb = await mongoose.connect(process.env.PRIMARY_DB);
|
|
32
|
+
* const analyticsDb = mongoose.createConnection(process.env.ANALYTICS_DB);
|
|
33
|
+
*
|
|
34
|
+
* const orderResource = defineResource({
|
|
35
|
+
* adapter: createMongooseAdapter({ model: OrderModel, repository: orderRepo }),
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* const analyticsResource = defineResource({
|
|
39
|
+
* adapter: createMongooseAdapter({ model: AnalyticsModel, repository: analyticsRepo }),
|
|
40
|
+
* });
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a production-ready Fastify application with Arc framework
|
|
45
|
+
*
|
|
46
|
+
* Security plugins are enabled by default (opt-out):
|
|
47
|
+
* - helmet (security headers)
|
|
48
|
+
* - cors (cross-origin requests)
|
|
49
|
+
* - rateLimit (DDoS protection)
|
|
50
|
+
* - underPressure (health monitoring)
|
|
51
|
+
*
|
|
52
|
+
* Note: Compression is not included due to known Fastify 5 issues.
|
|
53
|
+
* Use a reverse proxy (Nginx, Caddy) or CDN for compression.
|
|
54
|
+
*
|
|
55
|
+
* @param options - Application configuration
|
|
56
|
+
* @returns Configured Fastify instance
|
|
57
|
+
*/
|
|
58
|
+
declare function createApp(options: CreateAppOptions): Promise<FastifyInstance>;
|
|
59
|
+
/**
|
|
60
|
+
* Quick factory for common scenarios
|
|
61
|
+
*/
|
|
62
|
+
declare const ArcFactory: {
|
|
63
|
+
/**
|
|
64
|
+
* Create production app with strict security
|
|
65
|
+
*/
|
|
66
|
+
production(options: Omit<CreateAppOptions, "preset">): Promise<FastifyInstance>;
|
|
67
|
+
/**
|
|
68
|
+
* Create development app with relaxed security
|
|
69
|
+
*/
|
|
70
|
+
development(options: Omit<CreateAppOptions, "preset">): Promise<FastifyInstance>;
|
|
71
|
+
/**
|
|
72
|
+
* Create testing app with minimal setup
|
|
73
|
+
*/
|
|
74
|
+
testing(options: Omit<CreateAppOptions, "preset">): Promise<FastifyInstance>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export { ArcFactory as A, createApp as c };
|