@classytic/arc 2.11.4 → 2.13.1
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 +16 -12
- package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
- package/dist/EventTransport-CT_52aWU.d.mts +34 -0
- package/dist/EventTransport-DLWoUMHy.mjs +103 -0
- package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/audit.d.mts +199 -0
- package/dist/auth/audit.mjs +288 -0
- package/dist/auth/index.d.mts +3 -3
- package/dist/auth/index.mjs +117 -191
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +89 -13
- package/dist/cli/commands/describe.mjs +56 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +147 -48
- package/dist/cli/commands/init.d.mts +13 -0
- package/dist/cli/commands/init.mjs +130 -87
- package/dist/cli/commands/introspect.mjs +8 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/core-D72ia0EH.mjs +1399 -0
- package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
- package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
- package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
- package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
- package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
- package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
- package/dist/errors-j4aJm1Wg.mjs +184 -0
- package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
- package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
- package/dist/events/index.d.mts +6 -6
- package/dist/events/index.mjs +11 -35
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +1 -1
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
- package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
- package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
- package/dist/index.d.mts +6 -7
- package/dist/index.mjs +9 -10
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +60 -11
- package/dist/integrations/streamline.mjs +75 -85
- package/dist/integrations/websocket.mjs +2 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +2 -2
- package/dist/migrations/index.d.mts +23 -3
- package/dist/migrations/index.mjs +0 -7
- package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
- package/dist/{openapi-D7G1V7ex.mjs → openapi-CiOMVW1p.mjs} +143 -13
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
- package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +16 -31
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +6 -9
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +6 -8
- package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
- package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-C5coh64w.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
- package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
- package/dist/schemas/index.d.mts +100 -30
- package/dist/schemas/index.mjs +86 -29
- package/dist/scim/index.d.mts +264 -0
- package/dist/scim/index.mjs +963 -0
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +4 -4
- package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
- package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -8
- package/dist/testing/index.mjs +16 -24
- package/dist/types/index.d.mts +4 -4
- package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
- package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +5 -5
- package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
- package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +147 -51
- package/skills/arc/references/agent-auth.md +238 -0
- package/skills/arc/references/api-reference.md +187 -0
- package/skills/arc/references/auth.md +354 -7
- package/skills/arc/references/enterprise-auth.md +94 -0
- package/skills/arc/references/events.md +8 -6
- package/skills/arc/references/mcp.md +2 -2
- package/skills/arc/references/multi-tenancy.md +11 -2
- package/skills/arc/references/production.md +10 -9
- package/skills/arc/references/scim.md +247 -0
- package/skills/arc/references/testing.md +1 -1
- package/skills/arc-code-review/SKILL.md +141 -0
- package/skills/arc-code-review/references/anti-patterns.md +911 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
- package/skills/arc-code-review/references/migration-recipes.md +700 -0
- package/skills/arc-code-review/references/mongokit-migration.md +386 -0
- package/skills/arc-code-review/references/scaffolding.md +230 -0
- package/skills/arc-code-review/references/severity.md +127 -0
- package/dist/EventTransport-BFQjw9pB.mjs +0 -133
- package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-DUUiiimH.mjs +0 -964
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-CbcQRIch.mjs +0 -1054
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-Rg8axYPz.d.mts +0 -370
- /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
- /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
- /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
- /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
- /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
- /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
- /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
- /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
- /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
- /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
# Arc Migration Recipes — Before / After
|
|
2
|
+
|
|
3
|
+
Concrete diffs for the most common transformations. Apply in the order listed in [SKILL.md](../SKILL.md#audit-workflow). Each recipe shows the *minimum* arc replacement — add presets and field rules as the audit reveals them.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## §0. arc 2.x → 3.0 — every kit-specific adapter moves to its kit
|
|
8
|
+
|
|
9
|
+
**Why:** Through arc 2.12, kit-specific adapter factories (`createMongooseAdapter`, `createDrizzleAdapter`, `createPrismaAdapter`) shipped from `@classytic/arc` and arc had a peer dep on `@classytic/mongokit`. This coupled arc's release cadence to every kit's, dragged mongoose into every arc consumer's resolution graph (even Drizzle/Prisma users), and double-published the adapter contract types.
|
|
10
|
+
|
|
11
|
+
In arc 2.12:
|
|
12
|
+
- Adapter contract → `@classytic/repo-core/adapter` (new subpath in repo-core 0.4.0).
|
|
13
|
+
- Mongoose adapter → `@classytic/mongokit/adapter` (3.13.0).
|
|
14
|
+
- Drizzle adapter → `@classytic/sqlitekit/adapter` (0.3.0).
|
|
15
|
+
- Prisma adapter → `@classytic/prismakit/adapter` (0.1.0 — new kit).
|
|
16
|
+
- `mergeFieldRuleConstraints` + `applyNullable` → `@classytic/repo-core/schema`.
|
|
17
|
+
- `@classytic/mongokit`, `@prisma/client`, `mongoose` all dropped from arc's `peerDependencies`. Arc 2.12 has zero kit- or driver-bound peers.
|
|
18
|
+
- The `@classytic/arc/adapters` subpath was removed entirely. The `src/adapters/` directory inside arc is gone.
|
|
19
|
+
- `RepositoryLike` is still re-exported from `@classytic/arc` for convenience.
|
|
20
|
+
- Custom kits implementing `DataAdapter<TDoc>` plug in identically — same contract, no special-casing.
|
|
21
|
+
|
|
22
|
+
### Coordinated versions
|
|
23
|
+
|
|
24
|
+
| Package | Min |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `@classytic/arc` | 2.12.0 |
|
|
27
|
+
| `@classytic/repo-core` | 0.4.0 |
|
|
28
|
+
| `@classytic/mongokit` | 3.13.0 |
|
|
29
|
+
| `@classytic/sqlitekit` | 0.3.0 |
|
|
30
|
+
| `@classytic/prismakit` | 0.1.0 |
|
|
31
|
+
|
|
32
|
+
### Import migration table
|
|
33
|
+
|
|
34
|
+
| Old (arc 2.11.x) | New (arc 2.12+) |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `import { createMongooseAdapter } from '@classytic/arc'` | `import { createMongooseAdapter } from '@classytic/mongokit/adapter'` |
|
|
37
|
+
| `import { createMongooseAdapter } from '@classytic/arc/adapters'` | `import { createMongooseAdapter } from '@classytic/mongokit/adapter'` |
|
|
38
|
+
| `import { createDrizzleAdapter } from '@classytic/arc/adapters'` | `import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter'` |
|
|
39
|
+
| `import { createPrismaAdapter } from '@classytic/arc/adapters'` | `import { createPrismaAdapter } from '@classytic/prismakit/adapter'` |
|
|
40
|
+
| `import type { DataAdapter, RepositoryLike, AdapterRepositoryInput } from '@classytic/arc'` | `import type { DataAdapter, RepositoryLike, AdapterRepositoryInput } from '@classytic/repo-core/adapter'` |
|
|
41
|
+
| `import type { ... } from '@classytic/arc/adapters'` (any contract type) | `import type { ... } from '@classytic/repo-core/adapter'` |
|
|
42
|
+
| `import type { InferMongooseDoc } from '@classytic/arc/adapters'` | `import type { InferMongooseDoc } from '@classytic/mongokit/adapter'` |
|
|
43
|
+
| `import { mergeFieldRuleConstraints } from '@classytic/arc/adapters'` | `import { mergeFieldRuleConstraints } from '@classytic/repo-core/schema'` |
|
|
44
|
+
| `MongooseAdapter`, `DrizzleAdapter`, `PrismaAdapter` (classes) | Same path moves: `@classytic/mongokit/adapter` / `@classytic/sqlitekit/adapter` / `@classytic/prismakit/adapter` |
|
|
45
|
+
| Any `from '@classytic/arc/adapters'` import | The subpath was removed in arc 2.12 — re-route to the kit or repo-core per the rows above. |
|
|
46
|
+
|
|
47
|
+
### Mechanical steps
|
|
48
|
+
|
|
49
|
+
1. Bump versions in `package.json` to the matrix above. Drop `mongoose` from explicit deps if you only used it for the adapter — mongokit will pull it as its own peer.
|
|
50
|
+
2. Run a project-wide find/replace using the table above. Detection regex: see anti-patterns.md §32g.
|
|
51
|
+
3. `npx tsc --noEmit` — zero errors.
|
|
52
|
+
4. Smoke test the resource OpenAPI endpoint and one MCP tool — confirm the schema is non-empty (still requires `schemaGenerator`).
|
|
53
|
+
|
|
54
|
+
No runtime behavior change — the symbols are identical, only their package paths moved.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## §1. Manual CRUD module → `defineResource()`
|
|
59
|
+
|
|
60
|
+
**Scope:** typically 5 routes (`GET`, `GET /:id`, `POST`, `PATCH /:id`, `DELETE /:id`) + ~150 LOC of pagination/validation/permission glue per resource.
|
|
61
|
+
|
|
62
|
+
### Before
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// routes/products.ts (≈170 LOC)
|
|
66
|
+
import { Product } from '../models/product.js';
|
|
67
|
+
import { z } from 'zod';
|
|
68
|
+
|
|
69
|
+
const createBody = z.object({
|
|
70
|
+
name: z.string().min(1),
|
|
71
|
+
price: z.number().min(0),
|
|
72
|
+
status: z.enum(['draft', 'active', 'archived']).default('draft'),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export async function productRoutes(fastify) {
|
|
76
|
+
fastify.get('/products', async (req) => {
|
|
77
|
+
const page = Math.max(1, Number(req.query.page) || 1);
|
|
78
|
+
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
79
|
+
const filters: any = {};
|
|
80
|
+
if (req.query.status) filters.status = req.query.status;
|
|
81
|
+
if (req.user) filters.organizationId = req.user.orgId; // tenant scope
|
|
82
|
+
const items = await Product.find(filters)
|
|
83
|
+
.sort({ createdAt: -1 })
|
|
84
|
+
.skip((page - 1) * limit)
|
|
85
|
+
.limit(limit)
|
|
86
|
+
.lean();
|
|
87
|
+
const total = await Product.countDocuments(filters);
|
|
88
|
+
return { items, total, page, limit, pages: Math.ceil(total / limit) };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
fastify.get('/products/:id', async (req) => {
|
|
92
|
+
const item = await Product.findOne({ _id: req.params.id, organizationId: req.user?.orgId });
|
|
93
|
+
if (!item) throw fastify.httpErrors.notFound();
|
|
94
|
+
return item;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
fastify.post('/products', async (req, reply) => {
|
|
98
|
+
if (!req.user) throw fastify.httpErrors.unauthorized();
|
|
99
|
+
if (!req.user.roles.includes('admin')) throw fastify.httpErrors.forbidden();
|
|
100
|
+
const data = createBody.parse(req.body);
|
|
101
|
+
const item = await Product.create({ ...data, organizationId: req.user.orgId });
|
|
102
|
+
await fastify.events.emit('product.created', { id: item._id });
|
|
103
|
+
return reply.code(201).send(item);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
fastify.patch('/products/:id', async (req) => {
|
|
107
|
+
if (!req.user) throw fastify.httpErrors.unauthorized();
|
|
108
|
+
if (!req.user.roles.includes('admin')) throw fastify.httpErrors.forbidden();
|
|
109
|
+
const item = await Product.findOneAndUpdate(
|
|
110
|
+
{ _id: req.params.id, organizationId: req.user.orgId },
|
|
111
|
+
req.body,
|
|
112
|
+
{ new: true, runValidators: true },
|
|
113
|
+
);
|
|
114
|
+
if (!item) throw fastify.httpErrors.notFound();
|
|
115
|
+
return item;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
fastify.delete('/products/:id', async (req, reply) => {
|
|
119
|
+
if (!req.user || !req.user.roles.includes('admin')) throw fastify.httpErrors.forbidden();
|
|
120
|
+
const result = await Product.deleteOne({ _id: req.params.id, organizationId: req.user.orgId });
|
|
121
|
+
if (result.deletedCount === 0) throw fastify.httpErrors.notFound();
|
|
122
|
+
return reply.code(204).send();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### After
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// resources/product/product.resource.ts (≈30 LOC)
|
|
131
|
+
import { defineResource, requireRoles, allowPublic } from '@classytic/arc';
|
|
132
|
+
import { createMongooseAdapter } from '@classytic/mongokit/adapter';
|
|
133
|
+
import { Repository, buildCrudSchemasFromModel } from '@classytic/mongokit';
|
|
134
|
+
import { Product } from './product.model.js';
|
|
135
|
+
|
|
136
|
+
const productRepo = new Repository(Product);
|
|
137
|
+
|
|
138
|
+
export const productResource = defineResource({
|
|
139
|
+
name: 'product',
|
|
140
|
+
|
|
141
|
+
adapter: createMongooseAdapter({
|
|
142
|
+
model: Product,
|
|
143
|
+
repository: productRepo,
|
|
144
|
+
schemaGenerator: buildCrudSchemasFromModel,
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
presets: [{ name: 'multiTenant', tenantField: 'organizationId' }],
|
|
148
|
+
|
|
149
|
+
permissions: {
|
|
150
|
+
list: allowPublic(),
|
|
151
|
+
get: allowPublic(),
|
|
152
|
+
create: requireRoles(['admin']),
|
|
153
|
+
update: requireRoles(['admin']),
|
|
154
|
+
delete: requireRoles(['admin']),
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
schemaOptions: {
|
|
158
|
+
fieldRules: {
|
|
159
|
+
name: { type: 'string', minLength: 1, required: true },
|
|
160
|
+
price: { type: 'number', minimum: 0, required: true },
|
|
161
|
+
status: { type: 'string', enum: ['draft', 'active', 'archived'], default: 'draft' },
|
|
162
|
+
organizationId: { systemManaged: true },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
events: { created: {}, updated: {}, deleted: {} },
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// app.ts
|
|
172
|
+
import { createApp } from '@classytic/arc/factory';
|
|
173
|
+
import { productResource } from './resources/product/product.resource.js';
|
|
174
|
+
|
|
175
|
+
const app = await createApp({
|
|
176
|
+
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET! } },
|
|
177
|
+
resources: [productResource],
|
|
178
|
+
});
|
|
179
|
+
await app.listen({ port: 8040 });
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**What changed:**
|
|
183
|
+
- 170 LOC → 30 LOC (≈82% reduction).
|
|
184
|
+
- Tenant scoping via preset, not duplicated `organizationId` filter.
|
|
185
|
+
- `requireRoles(['admin'])` declarative, no inline 401/403 throws.
|
|
186
|
+
- Events auto-emitted; no `await fastify.events.emit('product.created', ...)`.
|
|
187
|
+
- Validation via `fieldRules` — both AJV (request) and OpenAPI (docs) derived.
|
|
188
|
+
- 404/422/409 mapped automatically via arc's error resolution chain.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## §2. Inline permission checks → declarative combinators
|
|
193
|
+
|
|
194
|
+
### Before
|
|
195
|
+
```typescript
|
|
196
|
+
fastify.patch('/posts/:id', async (req) => {
|
|
197
|
+
if (!req.user) throw fastify.httpErrors.unauthorized();
|
|
198
|
+
const post = await Post.findById(req.params.id);
|
|
199
|
+
if (!post) throw fastify.httpErrors.notFound();
|
|
200
|
+
const isAuthor = post.authorId.toString() === req.user.id;
|
|
201
|
+
const isAdmin = req.user.roles?.includes('admin');
|
|
202
|
+
if (!isAuthor && !isAdmin) throw fastify.httpErrors.forbidden();
|
|
203
|
+
// ... mutate
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### After
|
|
208
|
+
```typescript
|
|
209
|
+
import { defineResource, anyOf, requireRoles, requireOwnership } from '@classytic/arc';
|
|
210
|
+
|
|
211
|
+
defineResource({
|
|
212
|
+
name: 'post',
|
|
213
|
+
permissions: {
|
|
214
|
+
update: anyOf(requireRoles(['admin']), requireOwnership('authorId')),
|
|
215
|
+
delete: requireRoles(['admin']),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Ownership check is row-level (`requireOwnership` returns `filters: { authorId: userId }` for non-admins, propagated into the repo query).
|
|
221
|
+
|
|
222
|
+
For mixed human + service auth: `anyOf(requireOrgRole('admin'), requireServiceScope('jobs:bulk-write'))`.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## §3. Manual `toJSON` transforms → `fieldRules.hidden`
|
|
227
|
+
|
|
228
|
+
### Before
|
|
229
|
+
```typescript
|
|
230
|
+
// user.model.ts
|
|
231
|
+
const userSchema = new Schema({ name: String, email: String, password: String, mfaSecret: String });
|
|
232
|
+
userSchema.set('toJSON', {
|
|
233
|
+
transform: (_doc, ret) => {
|
|
234
|
+
delete ret.password;
|
|
235
|
+
delete ret.mfaSecret;
|
|
236
|
+
delete ret.__v;
|
|
237
|
+
ret.id = ret._id;
|
|
238
|
+
delete ret._id;
|
|
239
|
+
return ret;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
(And easy to forget on a new sensitive field. Doesn't fire on `.lean()`.)
|
|
244
|
+
|
|
245
|
+
### After
|
|
246
|
+
```typescript
|
|
247
|
+
// user.resource.ts
|
|
248
|
+
import { fields } from '@classytic/arc';
|
|
249
|
+
|
|
250
|
+
defineResource({
|
|
251
|
+
name: 'user',
|
|
252
|
+
schemaOptions: {
|
|
253
|
+
fieldRules: {
|
|
254
|
+
password: fields.hidden(),
|
|
255
|
+
mfaSecret: fields.hidden(),
|
|
256
|
+
salary: fields.visibleTo(['admin', 'hr']),
|
|
257
|
+
email: fields.redactFor(['viewer'], '***'),
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
Applies at framework serialization for both REST and MCP. Works with lean reads. New sensitive fields go in one place.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## §4. Hand-coded soft-delete → `presets: ['softDelete']`
|
|
267
|
+
|
|
268
|
+
### Before
|
|
269
|
+
```typescript
|
|
270
|
+
defineResource({
|
|
271
|
+
name: 'order',
|
|
272
|
+
schemaOptions: { fieldRules: { deletedAt: { type: 'date', nullable: true } } },
|
|
273
|
+
routes: [
|
|
274
|
+
{ method: 'GET', path: '/deleted', handler: 'listDeleted', permissions: requireRoles(['admin']) },
|
|
275
|
+
{ method: 'POST', path: '/:id/restore', handler: 'restore', permissions: requireRoles(['admin']) },
|
|
276
|
+
],
|
|
277
|
+
hooks: {
|
|
278
|
+
afterDelete: async (ctx) => repo.updateOne(ctx.meta.id, { deletedAt: new Date() }),
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
// Plus filter `deletedAt: null` injected in every read manually
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### After
|
|
285
|
+
```typescript
|
|
286
|
+
defineResource({
|
|
287
|
+
name: 'order',
|
|
288
|
+
presets: ['softDelete'], // adds /deleted, /:id/restore, deletedAt field, filter injection
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
Deep config: `{ name: 'softDelete', deletedField: 'archivedAt' }`.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## §5. Manual events → declarative `events` + `hooks`
|
|
296
|
+
|
|
297
|
+
### Before
|
|
298
|
+
```typescript
|
|
299
|
+
fastify.post('/orders', async (req, reply) => {
|
|
300
|
+
const order = await orderRepo.create(req.body);
|
|
301
|
+
await eventBus.emit('order.created', { id: order._id, customer: order.customerId });
|
|
302
|
+
return order;
|
|
303
|
+
});
|
|
304
|
+
fastify.patch('/orders/:id', async (req) => {
|
|
305
|
+
const order = await orderRepo.update(req.params.id, req.body);
|
|
306
|
+
// forgot to emit 'order.updated' — silent inconsistency
|
|
307
|
+
return order;
|
|
308
|
+
});
|
|
309
|
+
fastify.post('/orders/:id/refund', async (req) => {
|
|
310
|
+
await orderRepo.refund(req.params.id);
|
|
311
|
+
await eventBus.emit('order.refunded', { id: req.params.id });
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### After
|
|
316
|
+
```typescript
|
|
317
|
+
defineResource({
|
|
318
|
+
name: 'order',
|
|
319
|
+
events: {
|
|
320
|
+
created: {},
|
|
321
|
+
updated: {},
|
|
322
|
+
deleted: {},
|
|
323
|
+
refunded: { description: 'Order refunded', schema: { reason: 'string' } },
|
|
324
|
+
},
|
|
325
|
+
actions: {
|
|
326
|
+
refund: {
|
|
327
|
+
handler: async (id, data, req) => {
|
|
328
|
+
const result = await orderRepo.refund(id, data.reason);
|
|
329
|
+
await req.fastify.events.publish('order.refunded', { id, reason: data.reason });
|
|
330
|
+
return result;
|
|
331
|
+
},
|
|
332
|
+
permissions: requireRoles(['admin']),
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
CRUD events fire automatically. Custom events stay in handlers but are now co-located with permissions.
|
|
338
|
+
|
|
339
|
+
For at-least-once delivery, wire `EventOutbox`:
|
|
340
|
+
```typescript
|
|
341
|
+
import { EventOutbox } from '@classytic/arc/events';
|
|
342
|
+
new EventOutbox({ repository: outboxRepo, transport });
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## §6. Manual cache + invalidation → `cache` config
|
|
348
|
+
|
|
349
|
+
### Before
|
|
350
|
+
```typescript
|
|
351
|
+
fastify.get('/products', async (req) => {
|
|
352
|
+
const cached = await redis.get('products-list');
|
|
353
|
+
if (cached) return JSON.parse(cached);
|
|
354
|
+
const items = await Product.find({});
|
|
355
|
+
await redis.setex('products-list', 30, JSON.stringify(items));
|
|
356
|
+
return items;
|
|
357
|
+
});
|
|
358
|
+
fastify.post('/products', async (req) => {
|
|
359
|
+
const item = await Product.create(req.body);
|
|
360
|
+
await redis.del('products-list');
|
|
361
|
+
await redis.del(`product-${item._id}`);
|
|
362
|
+
return item;
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### After
|
|
367
|
+
```typescript
|
|
368
|
+
const app = await createApp({
|
|
369
|
+
arcPlugins: { queryCache: true },
|
|
370
|
+
stores: { queryCache: new RedisCacheStore({ client: redis }) },
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
defineResource({
|
|
374
|
+
name: 'product',
|
|
375
|
+
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
Mutations bump the resource version; reads see the new state on next miss. Response: `x-cache: HIT | STALE | MISS`.
|
|
379
|
+
|
|
380
|
+
For cross-resource invalidation:
|
|
381
|
+
```typescript
|
|
382
|
+
cache: { tags: ['catalog'], invalidateOn: { 'category.*': ['catalog'] } }
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## §7. `req.user._id` access → scope accessors
|
|
388
|
+
|
|
389
|
+
### Before
|
|
390
|
+
```typescript
|
|
391
|
+
app.post('/orders', async (req) => {
|
|
392
|
+
const userId = req.user._id; // crashes on public route
|
|
393
|
+
const orgId = req.user.orgId; // undefined for service tokens
|
|
394
|
+
return orderRepo.create({ ...req.body, userId, orgId });
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### After
|
|
399
|
+
```typescript
|
|
400
|
+
import { getUserId, getOrgId, isAuthenticated, hasOrgAccess } from '@classytic/arc/scope';
|
|
401
|
+
|
|
402
|
+
app.post('/orders', async (req, reply) => {
|
|
403
|
+
if (!isAuthenticated(req.scope)) return reply.unauthorized();
|
|
404
|
+
return orderRepo.create({
|
|
405
|
+
...req.body,
|
|
406
|
+
userId: getUserId(req.scope),
|
|
407
|
+
orgId: getOrgId(req.scope),
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
```
|
|
411
|
+
Even better — make it declarative via permission `filters` and let arc inject `userId`/`orgId` server-side:
|
|
412
|
+
```typescript
|
|
413
|
+
permissions: { create: allOf(requireAuth(), requireOrgMembership()) },
|
|
414
|
+
hooks: {
|
|
415
|
+
beforeCreate: async (ctx) => {
|
|
416
|
+
ctx.data.userId = getUserId(ctx.request.scope);
|
|
417
|
+
ctx.data.orgId = getOrgId(ctx.request.scope);
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
// Or use ownedByUser + multiTenant presets which inject these automatically.
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## §8. Driver imports leaking out of adapters
|
|
426
|
+
|
|
427
|
+
### Before
|
|
428
|
+
```typescript
|
|
429
|
+
// services/orderService.ts — service layer importing mongoose
|
|
430
|
+
import mongoose from 'mongoose';
|
|
431
|
+
import { Order } from '../models/order.js';
|
|
432
|
+
|
|
433
|
+
export class OrderService {
|
|
434
|
+
async fulfill(id: string) {
|
|
435
|
+
const session = await mongoose.startSession();
|
|
436
|
+
return session.withTransaction(async () => {
|
|
437
|
+
const order = await Order.findById(id).session(session);
|
|
438
|
+
// ...
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### After
|
|
445
|
+
```typescript
|
|
446
|
+
// services/orderService.ts — DB-agnostic
|
|
447
|
+
import type { RepositoryLike } from '@classytic/repo-core/adapter';
|
|
448
|
+
|
|
449
|
+
export class OrderService {
|
|
450
|
+
constructor(private orderRepo: RepositoryLike<Order>) {}
|
|
451
|
+
|
|
452
|
+
async fulfill(id: string) {
|
|
453
|
+
return this.orderRepo.withTransaction!(async (session) => {
|
|
454
|
+
const order = await this.orderRepo.getOne(id, { session });
|
|
455
|
+
// ...
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// adapters/order.adapter.ts — only place mongoose appears
|
|
461
|
+
import mongoose from 'mongoose';
|
|
462
|
+
import { Repository } from '@classytic/mongokit';
|
|
463
|
+
import { Order } from '../models/order.js';
|
|
464
|
+
export const orderRepo = new Repository(Order);
|
|
465
|
+
```
|
|
466
|
+
Service tests now use any `RepositoryLike` mock — no `mongodb-memory-server` required for unit tests.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## §9. Hand-rolled MCP tools → `mcpPlugin`
|
|
471
|
+
|
|
472
|
+
### Before
|
|
473
|
+
```typescript
|
|
474
|
+
// mcp/tools.ts — 200+ LOC of MCP plumbing
|
|
475
|
+
const tools = [
|
|
476
|
+
{
|
|
477
|
+
name: 'list_products',
|
|
478
|
+
description: 'List products',
|
|
479
|
+
inputSchema: { type: 'object', properties: { status: { type: 'string' } } },
|
|
480
|
+
handler: async (input) => Product.find(input).limit(20).lean(),
|
|
481
|
+
},
|
|
482
|
+
{ name: 'create_product', /* ... */ },
|
|
483
|
+
];
|
|
484
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
485
|
+
const tool = tools.find(t => t.name === request.params.name);
|
|
486
|
+
return tool.handler(request.params.arguments);
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### After
|
|
491
|
+
```typescript
|
|
492
|
+
import { mcpPlugin } from '@classytic/arc/mcp';
|
|
493
|
+
|
|
494
|
+
await app.register(mcpPlugin, {
|
|
495
|
+
resources: [productResource, orderResource],
|
|
496
|
+
auth: false, // or getAuth() / custom
|
|
497
|
+
exclude: ['credential'],
|
|
498
|
+
});
|
|
499
|
+
// 5 CRUD tools + custom routes + actions auto-generated per resource.
|
|
500
|
+
// Permissions and field rules carry through.
|
|
501
|
+
```
|
|
502
|
+
For domain-specific tools, use `extraTools: buildMcpToolsFromBridges([...])`.
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## §10. Hand-coded `Idempotency-Key` → `idempotencyPlugin`
|
|
507
|
+
|
|
508
|
+
### Before
|
|
509
|
+
```typescript
|
|
510
|
+
fastify.post('/payments', async (req) => {
|
|
511
|
+
const key = req.headers['idempotency-key'];
|
|
512
|
+
if (key) {
|
|
513
|
+
const existing = await Idempotency.findOne({ key });
|
|
514
|
+
if (existing) return existing.response;
|
|
515
|
+
}
|
|
516
|
+
const result = await processPayment(req.body);
|
|
517
|
+
if (key) await Idempotency.create({ key, response: result, expiresAt: ... });
|
|
518
|
+
return result;
|
|
519
|
+
});
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### After
|
|
523
|
+
```typescript
|
|
524
|
+
import { idempotencyPlugin } from '@classytic/arc/idempotency';
|
|
525
|
+
|
|
526
|
+
await app.register(idempotencyPlugin, {
|
|
527
|
+
repository: idempotencyRepo, // any RepositoryLike with getOne/deleteMany/findOneAndUpdate
|
|
528
|
+
ttlMs: 24 * 3600_000,
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
Applies to all mutating routes that accept `Idempotency-Key` header.
|
|
532
|
+
|
|
533
|
+
---
|
|
534
|
+
|
|
535
|
+
## §11. Manual job queue wiring → `jobsPlugin`
|
|
536
|
+
|
|
537
|
+
### Before
|
|
538
|
+
```typescript
|
|
539
|
+
import { Queue, Worker } from 'bullmq';
|
|
540
|
+
const emailQueue = new Queue('email', { connection: redis });
|
|
541
|
+
new Worker('email', processor, { connection: redis });
|
|
542
|
+
fastify.post('/users', async (req) => {
|
|
543
|
+
const user = await User.create(req.body);
|
|
544
|
+
await emailQueue.add('welcome', { userId: user._id });
|
|
545
|
+
});
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### After
|
|
549
|
+
```typescript
|
|
550
|
+
import { jobsPlugin } from '@classytic/arc/integrations/jobs';
|
|
551
|
+
|
|
552
|
+
await app.register(jobsPlugin, {
|
|
553
|
+
queues: { email: { handler: emailHandler } },
|
|
554
|
+
redis,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
defineResource({
|
|
558
|
+
name: 'user',
|
|
559
|
+
events: { created: {} },
|
|
560
|
+
hooks: {
|
|
561
|
+
afterCreate: async (ctx) => {
|
|
562
|
+
await ctx.fastify.jobs.email.add('welcome', { userId: ctx.result._id });
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
Or wire from an event handler subscribed to `user.created`.
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## §12. Custom controller → mixin composition
|
|
572
|
+
|
|
573
|
+
### Before — full custom controller (loses preset wiring)
|
|
574
|
+
```typescript
|
|
575
|
+
class OrderController {
|
|
576
|
+
constructor(private repo: OrderRepository) {}
|
|
577
|
+
async list(req) { /* manual pagination */ }
|
|
578
|
+
async getById(req) { /* manual lookup */ }
|
|
579
|
+
async create(req) { /* manual validation + permission */ }
|
|
580
|
+
async update(req) { /* ... */ }
|
|
581
|
+
async delete(req) { /* ... */ }
|
|
582
|
+
async restore(req) { /* manual soft-delete handling */ }
|
|
583
|
+
async bulkCreate(req) { /* manual bulk */ }
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### After — mixin composition
|
|
588
|
+
```typescript
|
|
589
|
+
import { BaseCrudController, SoftDeleteMixin, BulkMixin } from '@classytic/arc';
|
|
590
|
+
|
|
591
|
+
class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController))<Order> {
|
|
592
|
+
// domain methods only
|
|
593
|
+
async fulfill(id, data, req) { return orderService.fulfill(id, getUserId(req.scope)); }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
defineResource({
|
|
597
|
+
name: 'order',
|
|
598
|
+
controller: new OrderController(orderRepo, { resourceName: 'order' }),
|
|
599
|
+
presets: ['softDelete', 'bulk'],
|
|
600
|
+
routes: [{ method: 'POST', path: '/:id/fulfill', handler: 'fulfill', permissions: requireRoles(['admin']) }],
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
Available mixins: `SoftDeleteMixin`, `TreeMixin`, `SlugMixin`, `BulkMixin`. Or skip the controller entirely and let arc auto-build `BaseController`.
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## §13. Multi-tenancy from middleware → `multiTenantPreset`
|
|
608
|
+
|
|
609
|
+
### Before
|
|
610
|
+
```typescript
|
|
611
|
+
app.addHook('preHandler', async (req, reply) => {
|
|
612
|
+
const orgId = req.headers['x-org-id'];
|
|
613
|
+
if (!orgId) return reply.code(400).send({ error: 'Missing org' });
|
|
614
|
+
req.orgId = orgId;
|
|
615
|
+
});
|
|
616
|
+
fastify.get('/jobs', async (req) => Job.find({ orgId: req.orgId }));
|
|
617
|
+
fastify.post('/jobs', async (req) => Job.create({ ...req.body, orgId: req.orgId }));
|
|
618
|
+
// Repeated across every resource and every CRUD method
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### After
|
|
622
|
+
```typescript
|
|
623
|
+
defineResource({
|
|
624
|
+
name: 'job',
|
|
625
|
+
presets: [{ name: 'multiTenant', tenantField: 'organizationId' }],
|
|
626
|
+
});
|
|
627
|
+
// Tenant filter auto-applied to every read; org auto-injected on create/update;
|
|
628
|
+
// body 'organizationId' overwritten with caller's scope on update (closes tenant-hop).
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
For multi-level (org + branch + project):
|
|
632
|
+
```typescript
|
|
633
|
+
import { multiTenantPreset } from '@classytic/arc/presets';
|
|
634
|
+
|
|
635
|
+
defineResource({
|
|
636
|
+
presets: [multiTenantPreset({
|
|
637
|
+
tenantFields: [
|
|
638
|
+
{ field: 'organizationId', type: 'org' },
|
|
639
|
+
{ field: 'branchId', contextKey: 'branchId' },
|
|
640
|
+
{ field: 'projectId', contextKey: 'projectId' },
|
|
641
|
+
],
|
|
642
|
+
})],
|
|
643
|
+
});
|
|
644
|
+
```
|
|
645
|
+
Populate `scope.context` and `scope.ancestorOrgIds` in the auth function — see `references/multi-tenancy.md` in the `arc` skill.
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## §14. Test setup: hand-rolled in-memory Mongo → `createTestApp`
|
|
650
|
+
|
|
651
|
+
### Before
|
|
652
|
+
```typescript
|
|
653
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
654
|
+
import mongoose from 'mongoose';
|
|
655
|
+
let server;
|
|
656
|
+
beforeAll(async () => {
|
|
657
|
+
server = await MongoMemoryServer.create();
|
|
658
|
+
await mongoose.connect(server.getUri());
|
|
659
|
+
});
|
|
660
|
+
afterAll(async () => { await mongoose.disconnect(); await server.stop(); });
|
|
661
|
+
test('creates product', async () => {
|
|
662
|
+
const app = buildApp(); /* ... custom auth header construction ... */
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### After
|
|
667
|
+
```typescript
|
|
668
|
+
import { createTestApp, expectArc } from '@classytic/arc/testing';
|
|
669
|
+
|
|
670
|
+
test('creates product', async () => {
|
|
671
|
+
const ctx = await createTestApp({
|
|
672
|
+
resources: [productResource],
|
|
673
|
+
authMode: 'jwt',
|
|
674
|
+
connectMongoose: true,
|
|
675
|
+
});
|
|
676
|
+
ctx.auth.register('admin', { user: { id: '1', role: 'admin' }, orgId: 'org-1' });
|
|
677
|
+
|
|
678
|
+
const res = await ctx.app.inject({
|
|
679
|
+
method: 'POST', url: '/products',
|
|
680
|
+
headers: ctx.auth.as('admin').headers,
|
|
681
|
+
payload: { name: 'Widget', price: 10 },
|
|
682
|
+
});
|
|
683
|
+
expectArc(res).ok().hidesField('password');
|
|
684
|
+
|
|
685
|
+
await ctx.close();
|
|
686
|
+
});
|
|
687
|
+
```
|
|
688
|
+
Or `createHttpTestHarness(productResource)` to auto-generate ~16 CRUD/permission/validation tests per resource.
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## Rollout strategy for large projects
|
|
693
|
+
|
|
694
|
+
1. **Pick one resource** with thin business logic (no domain edge cases). Migrate end-to-end.
|
|
695
|
+
2. **Land the auth + scope changes first.** They unlock declarative permissions for every subsequent resource.
|
|
696
|
+
3. **Migrate adapters before resources.** A resource without an adapter migration is a half-step.
|
|
697
|
+
4. **Bundle preset adoption with the relevant resource.** Don't introduce `softDelete` as a separate PR — change the resource that needs it.
|
|
698
|
+
5. **Keep old routes alongside new for one release.** Tag with `versioningPlugin` or a path prefix; cut over after consumer confirmation.
|
|
699
|
+
6. **Run `arc docs ./openapi.json`** at the end and diff against the hand-maintained spec — discrepancies are bugs in either side.
|
|
700
|
+
7. **Drop the old code** once the report shows zero hits for the §3 / §4 / §7 patterns.
|