@classytic/arc 2.11.4 → 2.14.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.
Files changed (167) hide show
  1. package/README.md +16 -12
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/audit.d.mts +199 -0
  9. package/dist/auth/audit.mjs +288 -0
  10. package/dist/auth/index.d.mts +3 -3
  11. package/dist/auth/index.mjs +117 -191
  12. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  13. package/dist/buildHandler-olo-gt94.mjs +610 -0
  14. package/dist/cache/index.mjs +3 -3
  15. package/dist/cli/commands/describe.d.mts +89 -13
  16. package/dist/cli/commands/describe.mjs +56 -2
  17. package/dist/cli/commands/docs.mjs +2 -2
  18. package/dist/cli/commands/generate.mjs +147 -48
  19. package/dist/cli/commands/init.d.mts +13 -0
  20. package/dist/cli/commands/init.mjs +130 -87
  21. package/dist/cli/commands/introspect.mjs +8 -1
  22. package/dist/context/index.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +5 -5
  25. package/dist/core-DECn6zaU.mjs +1399 -0
  26. package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CBxLLbn3.mjs} +7 -20
  27. package/dist/createAggregationRouter-CRIBv4sC.mjs +114 -0
  28. package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
  29. package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
  30. package/dist/docs/index.d.mts +24 -11
  31. package/dist/docs/index.mjs +2 -2
  32. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  33. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  34. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  35. package/dist/errors-j4aJm1Wg.mjs +184 -0
  36. package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
  37. package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
  38. package/dist/events/index.d.mts +6 -6
  39. package/dist/events/index.mjs +11 -35
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +2 -2
  43. package/dist/factory/index.mjs +2 -2
  44. package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
  45. package/dist/hooks/index.d.mts +1 -1
  46. package/dist/hooks/index.mjs +1 -1
  47. package/dist/idempotency/index.d.mts +1 -1
  48. package/dist/idempotency/index.mjs +1 -20
  49. package/dist/idempotency/redis.mjs +1 -1
  50. package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
  51. package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
  52. package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
  53. package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  54. package/dist/index.d.mts +6 -7
  55. package/dist/index.mjs +9 -10
  56. package/dist/integrations/event-gateway.d.mts +1 -1
  57. package/dist/integrations/event-gateway.mjs +1 -1
  58. package/dist/integrations/index.d.mts +1 -1
  59. package/dist/integrations/mcp/index.d.mts +2 -2
  60. package/dist/integrations/mcp/index.mjs +1 -1
  61. package/dist/integrations/mcp/testing.d.mts +1 -1
  62. package/dist/integrations/mcp/testing.mjs +1 -1
  63. package/dist/integrations/streamline.d.mts +60 -11
  64. package/dist/integrations/streamline.mjs +75 -85
  65. package/dist/integrations/websocket.mjs +2 -8
  66. package/dist/middleware/index.d.mts +1 -1
  67. package/dist/middleware/index.mjs +2 -2
  68. package/dist/migrations/index.d.mts +23 -3
  69. package/dist/migrations/index.mjs +0 -7
  70. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  71. package/dist/openapi-noXno2CV.mjs +968 -0
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/org/index.mjs +1 -1
  74. package/dist/permissions/index.d.mts +3 -3
  75. package/dist/permissions/index.mjs +3 -3
  76. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  77. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  78. package/dist/pipeline/index.d.mts +1 -1
  79. package/dist/pipeline/index.mjs +1 -1
  80. package/dist/plugins/index.d.mts +16 -31
  81. package/dist/plugins/index.mjs +33 -13
  82. package/dist/plugins/response-cache.mjs +1 -1
  83. package/dist/plugins/tracing-entry.mjs +1 -1
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +6 -9
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +1 -1
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +2 -2
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +6 -8
  92. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  93. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  94. package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
  95. package/dist/registry/index.d.mts +1 -1
  96. package/dist/registry/index.mjs +2 -2
  97. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  98. package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-DLL32us3.mjs} +224 -71
  99. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-DrOa-26E.mjs} +41 -36
  100. package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-lYhC2gE5.mjs} +1 -1
  101. package/dist/schemas/index.d.mts +100 -30
  102. package/dist/schemas/index.mjs +86 -29
  103. package/dist/scim/index.d.mts +264 -0
  104. package/dist/scim/index.mjs +963 -0
  105. package/dist/scope/index.d.mts +3 -3
  106. package/dist/scope/index.mjs +4 -4
  107. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  108. package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  109. package/dist/testing/index.d.mts +2 -8
  110. package/dist/testing/index.mjs +16 -24
  111. package/dist/types/index.d.mts +4 -4
  112. package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
  113. package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
  114. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  115. package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -2
  117. package/dist/utils/index.mjs +5 -5
  118. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  119. package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  120. package/package.json +24 -34
  121. package/skills/arc/SKILL.md +147 -51
  122. package/skills/arc/references/agent-auth.md +238 -0
  123. package/skills/arc/references/api-reference.md +187 -0
  124. package/skills/arc/references/auth.md +354 -7
  125. package/skills/arc/references/enterprise-auth.md +94 -0
  126. package/skills/arc/references/events.md +8 -6
  127. package/skills/arc/references/mcp.md +2 -2
  128. package/skills/arc/references/multi-tenancy.md +11 -2
  129. package/skills/arc/references/production.md +10 -9
  130. package/skills/arc/references/scim.md +247 -0
  131. package/skills/arc/references/testing.md +1 -1
  132. package/skills/arc-code-review/SKILL.md +141 -0
  133. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  134. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  135. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  136. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  137. package/skills/arc-code-review/references/scaffolding.md +230 -0
  138. package/skills/arc-code-review/references/severity.md +127 -0
  139. package/dist/EventTransport-BFQjw9pB.mjs +0 -133
  140. package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
  141. package/dist/adapters/index.d.mts +0 -3
  142. package/dist/adapters/index.mjs +0 -2
  143. package/dist/adapters-DUUiiimH.mjs +0 -964
  144. package/dist/auth/mongoose.d.mts +0 -191
  145. package/dist/auth/mongoose.mjs +0 -73
  146. package/dist/core-CbcQRIch.mjs +0 -1054
  147. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  148. package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
  149. package/dist/errors-D5c-5BJL.mjs +0 -232
  150. package/dist/index-Rg8axYPz.d.mts +0 -370
  151. package/dist/openapi-D7G1V7ex.mjs +0 -557
  152. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  153. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  154. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  155. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  156. /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  157. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  158. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  159. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  160. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  161. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
  162. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  163. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  164. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  165. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  166. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  167. /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.