@classytic/arc 2.20.0 → 2.22.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 (143) hide show
  1. package/dist/{BaseController-D-MHZ27_.mjs → BaseController-C6FlRWxF.mjs} +2 -2
  2. package/dist/{EventTransport-IEn7Tn-Q.d.mts → EventTransport-QmY7A258.d.mts} +0 -1
  3. package/dist/{QueryCache-TRfXau3W.d.mts → QueryCache-Z1B38koh.d.mts} +0 -1
  4. package/dist/{actionPermissions-C9RMiIpc.mjs → actionPermissions-ze70xXJi.mjs} +1 -1
  5. package/dist/audit/index.d.mts +9 -5
  6. package/dist/auth/audit.d.mts +7 -4
  7. package/dist/auth/audit.mjs +5 -0
  8. package/dist/auth/index.d.mts +35 -14
  9. package/dist/auth/index.mjs +6 -3
  10. package/dist/auth/redis-session.d.mts +1 -2
  11. package/dist/cache/index.d.mts +2 -3
  12. package/dist/cli/commands/describe.d.mts +1 -2
  13. package/dist/cli/commands/docs.mjs +1 -1
  14. package/dist/cli/commands/init.mjs +1 -1
  15. package/dist/cli/index.mjs +1 -1
  16. package/dist/context/index.d.mts +2 -2
  17. package/dist/core/index.d.mts +3 -3
  18. package/dist/core/index.mjs +4 -4
  19. package/dist/{core-CWhyGkiM.mjs → core-b6fgFgnd.mjs} +129 -17
  20. package/dist/{createActionRouter-HlQso0jO.mjs → createActionRouter-h1cEEaEX.mjs} +10 -6
  21. package/dist/{createAggregationRouter-5LzPzgw1.mjs → createAggregationRouter-B1C0kvTL.mjs} +3 -3
  22. package/dist/{createApp-D6uwzCKu.mjs → createApp-Ci7yG5gv.mjs} +390 -192
  23. package/dist/discovery/index.d.mts +0 -1
  24. package/dist/docs/index.d.mts +1 -2
  25. package/dist/docs/index.mjs +1 -1
  26. package/dist/{elevation-xGUBq6p_.d.mts → elevation-C9s5hFP_.d.mts} +0 -1
  27. package/dist/encryption/index.d.mts +2 -144
  28. package/dist/encryption/index.mjs +9 -5
  29. package/dist/{errorHandler-Dxf6P_kV.mjs → errorHandler-BTzXSwIg.mjs} +24 -3
  30. package/dist/{errorHandler-BtUUDPEa.d.mts → errorHandler-C6yv4ZTW.d.mts} +13 -1
  31. package/dist/{errors-DOHkjQlD.d.mts → errors-DHcHVAgk.d.mts} +0 -1
  32. package/dist/{eventPlugin-DbBFd3x_.d.mts → eventPlugin-A6EbthXR.d.mts} +10 -6
  33. package/dist/events/index.d.mts +9 -6
  34. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  35. package/dist/events/transports/redis.d.mts +1 -2
  36. package/dist/factory/index.d.mts +30 -9
  37. package/dist/factory/index.mjs +35 -4
  38. package/dist/{fastify-augmentation-CCQJEDgh.d.mts → fastify-augmentation-9_fuetB4.d.mts} +2 -3
  39. package/dist/fields-D3lhgzhD.d.mts +121 -0
  40. package/dist/health-ScyjIVo5.mjs +202 -0
  41. package/dist/hooks/index.d.mts +1 -1
  42. package/dist/idempotency/index.d.mts +4 -3
  43. package/dist/idempotency/index.mjs +2 -0
  44. package/dist/idempotency/redis.d.mts +1 -1
  45. package/dist/{types-BHMKlMBU.d.mts → index-BEu4wjbL.d.mts} +241 -80
  46. package/dist/{index-DUa8WHz3.d.mts → index-D8MKYGDa.d.mts} +88 -7
  47. package/dist/{index-_--GyzcO.d.mts → index-DbtgCOCj.d.mts} +1324 -1228
  48. package/dist/{index-CdFkIMEd.d.mts → index-ogIHvOjo.d.mts} +36 -7
  49. package/dist/index.d.mts +8 -7
  50. package/dist/index.mjs +6 -6
  51. package/dist/{init-YH_nslQR.mjs → init-DleK3p9f.mjs} +272 -139
  52. package/dist/integrations/event-gateway.d.mts +2 -3
  53. package/dist/integrations/index.d.mts +2 -2
  54. package/dist/integrations/jobs.d.mts +0 -1
  55. package/dist/integrations/mcp/index.d.mts +6 -7
  56. package/dist/integrations/mcp/index.mjs +1 -1
  57. package/dist/integrations/mcp/testing.d.mts +1 -2
  58. package/dist/integrations/mcp/testing.mjs +1 -1
  59. package/dist/integrations/streamline.d.mts +2 -2
  60. package/dist/integrations/webhooks.d.mts +8 -5
  61. package/dist/integrations/webhooks.mjs +9 -5
  62. package/dist/integrations/websocket-pushref-redis.d.mts +1 -2
  63. package/dist/integrations/websocket-redis.d.mts +1 -2
  64. package/dist/integrations/websocket.d.mts +2 -2
  65. package/dist/jsonBody-BlzAoGCe.mjs +43 -0
  66. package/dist/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
  67. package/dist/logger/index.d.mts +26 -1
  68. package/dist/logger/index.mjs +39 -1
  69. package/dist/{mcpPlugin-CuW6L_uD.mjs → mcpPlugin-ByA2tf6D.mjs} +8 -4
  70. package/dist/middleware/index.d.mts +1 -2
  71. package/dist/{module-JYH9V0I8.d.mts → module-B4u10iOJ.d.mts} +71 -1
  72. package/dist/{openapi-Dq6RbEGK.mjs → openapi-BRpMZD7C.mjs} +3 -3
  73. package/dist/permissions/index.d.mts +4 -3
  74. package/dist/permissions/index.mjs +2 -2
  75. package/dist/{permissions-CgbOLJZG.mjs → permissions-BJMuKMl7.mjs} +33 -1
  76. package/dist/pipeline/index.d.mts +1 -2
  77. package/dist/plugins/index.d.mts +96 -7
  78. package/dist/plugins/index.mjs +86 -268
  79. package/dist/plugins/response-cache.d.mts +5 -3
  80. package/dist/plugins/response-cache.mjs +1 -0
  81. package/dist/plugins/tracing-entry.d.mts +1 -1
  82. package/dist/plugins/tracing-entry.mjs +7 -4
  83. package/dist/presets/filesUpload.d.mts +4 -5
  84. package/dist/presets/filesUpload.mjs +1 -1
  85. package/dist/presets/index.d.mts +1 -2
  86. package/dist/presets/index.mjs +1 -1
  87. package/dist/presets/multiTenant.d.mts +1 -2
  88. package/dist/presets/search.d.mts +2 -3
  89. package/dist/presets/search.mjs +1 -1
  90. package/dist/{presets-DWsYQdbV.mjs → presets-gw0ciIUD.mjs} +1 -1
  91. package/dist/{pushref-registry-DnCqL3aa.d.mts → pushref-registry-BbcnpNTm.d.mts} +4 -2
  92. package/dist/{queryCachePlugin-xzFyKAjT.d.mts → queryCachePlugin-CfYWq-Ak.d.mts} +1 -2
  93. package/dist/rateLimitKey-BoWRH3e7.mjs +14 -0
  94. package/dist/{redis-C3W0qbz8.d.mts → redis-UDtLaNj6.d.mts} +0 -1
  95. package/dist/{redis-stream-DsP1vqZB.d.mts → redis-stream-iVR2l7aS.d.mts} +1 -2
  96. package/dist/{registerRoute-DGSREwPw.mjs → registerRoute-xHc42fwB.mjs} +38 -18
  97. package/dist/registry/index.d.mts +2 -2
  98. package/dist/requestId-5BhTVzBv.mjs +113 -0
  99. package/dist/schemaConverter-Ds5reSGs.mjs +168 -0
  100. package/dist/{schemaIR-ajetw3Fv.mjs → schemaIR-B-6SPe7g.mjs} +2 -2
  101. package/dist/schemas/index.d.mts +0 -1
  102. package/dist/scim/index.d.mts +0 -1
  103. package/dist/scim/index.mjs +6 -7
  104. package/dist/scope/index.d.mts +2 -3
  105. package/dist/scope/index.mjs +2 -76
  106. package/dist/scope-k15RXfcq.mjs +64 -0
  107. package/dist/{sessionManager-BXG-HGwG.d.mts → sessionManager-BHOM3N_0.d.mts} +10 -6
  108. package/dist/{storage-Co_PQpeQ.d.mts → storage-DLUZ04kK.d.mts} +8 -4
  109. package/dist/testing/index.d.mts +83 -4
  110. package/dist/testing/index.mjs +74 -4
  111. package/dist/testing/storageContract.d.mts +1 -2
  112. package/dist/{tracing-D8jlGXP7.d.mts → tracing-CsGKQJGg.d.mts} +0 -1
  113. package/dist/types/index.d.mts +4 -4
  114. package/dist/types/storage.d.mts +1 -1
  115. package/dist/{types-C9b2HR5W.d.mts → types-Bc42CUyk.d.mts} +1 -2
  116. package/dist/types-Cc91nXuA.d.mts +132 -0
  117. package/dist/{fields-C7ahdGYf.d.mts → types-Csxga7d3.d.mts} +2 -123
  118. package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
  119. package/dist/usage/index.d.mts +142 -0
  120. package/dist/usage/index.mjs +0 -0
  121. package/dist/utils/index.d.mts +220 -144
  122. package/dist/utils/index.mjs +3 -3
  123. package/dist/{utils-ChjYQpjn.mjs → utils-FhJ88TJE.mjs} +129 -39
  124. package/dist/{versioning-D_eBCfNr.d.mts → versioning-DLwklPlG.d.mts} +1 -7
  125. package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
  126. package/llms.txt +39 -0
  127. package/package.json +453 -447
  128. package/skills/arc/SKILL.md +14 -8
  129. package/skills/arc/references/api-reference.md +34 -0
  130. package/skills/arc/references/auth.md +21 -4
  131. package/skills/arc/references/mcp.md +1 -1
  132. package/skills/arc/references/production.md +44 -2
  133. package/skills/arc-code-review/SKILL.md +1 -1
  134. package/skills/arc-code-review/references/anti-patterns.md +71 -18
  135. package/skills/arc-code-review/references/arc-cheatsheet.md +15 -2
  136. package/skills/arc-code-review/references/migration-recipes.md +7 -7
  137. package/skills/arc-code-review/references/mongokit-migration.md +4 -3
  138. package/skills/arc-module-publishing/SKILL.md +243 -0
  139. package/skills/arc-module-publishing/references/authoring-modules.md +327 -0
  140. package/skills/arc-module-publishing/references/package-manifest.md +157 -0
  141. package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
  142. package/dist/preloadResources-CHw3_oA_.mjs +0 -45
  143. package/dist/schemaConverter-U0MnO9vd.mjs +0 -110
@@ -0,0 +1,243 @@
1
+ ---
2
+ name: arc-module-publishing
3
+ description: |
4
+ Author and publish an ecosystem package for @classytic/arc the way arc's own packages
5
+ are built — modules, action presets, and plugins that hosts install and compose.
6
+ Codifies the non-negotiable conventions: no hardcoded dependencies (host duty), peers
7
+ as `>=` ranges (stable API contract), bundle-nothing tsdown, public-subpath imports only,
8
+ one-way dependency (arc never depends on you), and the classytic release gate.
9
+ Use when creating a new @classytic/arc-<domain> package, extracting a domain out of an
10
+ app into a reusable module, deciding a package's package.json / tsdown / peer setup,
11
+ writing a defineModule() or an action preset, or preparing an arc module for npm publish.
12
+ Triggers: publish arc module, arc package, arc ecosystem, defineModule, ArcModule, arc
13
+ plugin package, arc action preset, extract domain to package, arc-approval, arc-involvement,
14
+ peer dependencies arc, tsdown arc package, arc module conventions, classytic publish,
15
+ mergeResourceConfig, ResourceSeams, kernel-typed engine port, module errorMappers.
16
+ license: MIT
17
+ metadata:
18
+ author: Classytic
19
+ tags:
20
+ - arc
21
+ - packaging
22
+ - publishing
23
+ - modules
24
+ - ecosystem
25
+ - tsdown
26
+ - peer-dependencies
27
+ - monorepo
28
+ ---
29
+
30
+ # Publishing @classytic/arc ecosystem packages
31
+
32
+ How to author and ship a reusable package **on top of** arc — a domain module, an action
33
+ preset, or a plugin — so it composes cleanly into any host and follows the same discipline
34
+ as arc's own first-party packages (`@classytic/arc-approval`, `@classytic/arc-involvement`,
35
+ `@classytic/arc-notifications`).
36
+
37
+ **Node ≥22 · ESM only · TypeScript strict · tsdown build · Biome lint.**
38
+
39
+ ---
40
+
41
+ ## The one rule everything else follows
42
+
43
+ > **The package declares a contract; the host owns the installation.**
44
+
45
+ A published arc package is a *contribution* to someone else's app, not an app. That single
46
+ idea produces every convention below: you never bundle what the host provides, you never
47
+ pin what the host must choose, you never reach past arc's public surface, and you never
48
+ make arc depend on you.
49
+
50
+ ---
51
+
52
+ ## Decision: which shape are you shipping?
53
+
54
+ Three shapes, smallest surface first. Pick the smallest that does the job.
55
+
56
+ | Shape | You export | Host uses it via | Example |
57
+ |---|---|---|---|
58
+ | **Action preset** | a function returning an `actions:` slice | spread into `defineResource({ actions })` | `withApprovalChain()` (arc-approval) |
59
+ | **Plugin** | an `fp()`-wrapped Fastify plugin (+ named export) | `app.register()` / `plugins:` slot | any cross-cutting decorator/hook |
60
+ | **Module** | `createXModule(deps) → ArcModule` | `createApp({ modules: [...] })` | a whole domain (engine + resources + wiring) |
61
+
62
+ - A preset adds **verbs to one resource**. No lifecycle, no engine — just a function that
63
+ returns `Record<string, ActionDefinition>`. Storage-agnostic via a **structural repository
64
+ port** (an interface with the two or three methods you actually call), never a kit import.
65
+ - A module owns a **whole domain**: it initializes an engine in `bootstrap`, registers its
66
+ resources, wires `afterResources`, and tears down in `onClose`. Use `defineModule` and let
67
+ the host list it in `modules:`. See [references/authoring-modules.md](references/authoring-modules.md).
68
+
69
+ When unsure, ship a **preset or plugin**. A module is the right call only when there is
70
+ engine state to boot and multiple resources to register as a unit.
71
+
72
+ ---
73
+
74
+ ## Dependency discipline (the part reviewers keep catching)
75
+
76
+ This is where packages go wrong. Get the `package.json` dependency fields exactly right:
77
+
78
+ - **`dependencies`: usually empty.** Everything arc-shaped is a *peer*. Only add a real
79
+ `dependency` for a genuinely private, bundled-in helper the host should never see or
80
+ control — which is rare. If you're tempted to add `@classytic/*` or `fastify` here, stop:
81
+ that's a peer.
82
+ - **`peerDependencies`: `>=` ranges, never `^`.** A peer is a *contract*: "I work against
83
+ arc 2.20 and up." `^2.20.0` would wrongly forbid a host on arc 3.x that still satisfies the
84
+ API you use. Use `">=2.20.0"`. This keeps the API contract stable and lets hosts upgrade arc
85
+ without your package blocking them.
86
+ - **`devDependencies`: pin with `^`.** These are the versions your *test suite* runs against —
87
+ a concrete, current version (`"@classytic/arc": "^2.20.0"`). The floor of every devDep must
88
+ be `>=` the peer floor, or your tests never exercise the version you claim to support.
89
+ - **Optional peers** (zod, ioredis, a kit) go in `peerDependencies` **and**
90
+ `peerDependenciesMeta: { "<name>": { optional: true } }`. Never let their types leak into
91
+ your public `.d.mts` — use structural typing so a host that doesn't install them still
92
+ compiles. See [references/package-manifest.md](references/package-manifest.md).
93
+
94
+ **Why `>=` and not `^` for peers:** the host, not you, resolves the actual installed version.
95
+ Your job is to state the *floor* of the API you depend on and stay out of the host's upgrade
96
+ path. A caret peer turns every host major-bump into a forced wait on your release.
97
+
98
+ ---
99
+
100
+ ## Import discipline: public surface only
101
+
102
+ Import arc through its **published subpaths**, exactly as a third-party host would. Never
103
+ reach into `@classytic/arc/src/...` or a deep `dist/` path.
104
+
105
+ ```ts
106
+ import type { PermissionCheck } from "@classytic/arc/permissions";
107
+ import { getOrgId, getUserId, requireOrgId } from "@classytic/arc/scope";
108
+ import type { ActionDefinition, RequestWithExtras } from "@classytic/arc/types";
109
+ import { createDomainError, NotFoundError, ValidationError } from "@classytic/arc/utils";
110
+ ```
111
+
112
+ The canonical entry points: `@classytic/arc` (core), `/permissions`, `/scope`, `/types`,
113
+ `/utils`, `/factory` (for `defineModule`, `getModuleExports`), `/testing`. If something you
114
+ need isn't on a public subpath, that's a signal — file it against arc rather than deep-import
115
+ around it. Deep imports break the moment arc reorganizes internals, which it's free to do
116
+ because they were never public.
117
+
118
+ **One-way dependency:** your package depends on arc; **arc never depends on your package.**
119
+ If arc would need something from you, the abstraction belongs *in arc* (a port, a hook, a
120
+ preset seam), not as a back-edge. This keeps the dependency graph a DAG and arc publishable
121
+ on its own.
122
+
123
+ ---
124
+
125
+ ## Build: bundle nothing
126
+
127
+ tsdown, ESM only, and **bundle nothing that's a peer**. Every `@classytic/*` and `fastify`
128
+ stays external so the host's single installed copy is the one that runs (two copies of arc =
129
+ two plugin registries = subtle breakage).
130
+
131
+ ```ts
132
+ // tsdown.config.ts
133
+ import { defineConfig } from "tsdown";
134
+
135
+ export default defineConfig({
136
+ entry: ["src/index.ts"],
137
+ format: ["esm"],
138
+ dts: true,
139
+ sourcemap: false,
140
+ clean: true,
141
+ treeshake: true,
142
+ target: "node22",
143
+ outDir: "dist",
144
+ deps: {
145
+ skipNodeModulesBundle: true, // don't inline node_modules
146
+ neverBundle: [/^@classytic\//], // arc + primitives + kits stay external, always
147
+ },
148
+ });
149
+ ```
150
+
151
+ `package.json` build wiring: `sideEffects: false`, `files: ["dist"]`, an `exports` map with
152
+ `types` + `default`, `type: "module"`, `engines.node: ">=22"`, `publishConfig.access:
153
+ "public"`. Full template in [references/package-manifest.md](references/package-manifest.md).
154
+
155
+ ---
156
+
157
+ ## Bridge onto arc's primitives — don't reinvent them
158
+
159
+ A hard-won lesson: when a host already gets a capability from arc, **bridge onto it** instead
160
+ of shipping a second path.
161
+
162
+ - Need background work? Use `fastify.jobs` (arc's BullMQ bridge) — don't add your own BullMQ
163
+ dependency and stand up a parallel queue. Two job systems in one app is a footgun, not a
164
+ feature.
165
+ - Need guaranteed event delivery? Use arc's **outbox** and the `@classytic/primitives` event
166
+ contract (`createEvent`, `DomainEvent`, `EventTransport`) — don't invent an event shape.
167
+ - Need permissions/scope? Use `@classytic/arc/permissions` + `@classytic/arc/scope`
168
+ accessors (`getOrgId`, `requireUserId`) — don't re-derive tenant/user from `request.user`.
169
+
170
+ The test: *"Does arc already bless a primitive for this?"* If yes, your package composes onto
171
+ it. Shipping a competing path breaks the host's single-source-of-truth and doubles their ops
172
+ surface. Reinvent only when arc genuinely has no primitive — and then consider whether it
173
+ should live in arc.
174
+
175
+ ---
176
+
177
+ ## Naming & versioning
178
+
179
+ - **Package name:** `@classytic/arc-<domain>` for first-party; third parties use their own
180
+ scope (`@acme/arc-billing`). The `arc-` prefix signals "arc ecosystem package."
181
+ - **Module `name`** (the `defineModule({ name })` identifier): namespace it so it can't
182
+ collide in a host that composes many modules — `"accounting"`, `"order"`, or a
183
+ vendor-qualified `"acme.billing"`. This is the key hosts use in `dependsOn` and
184
+ `getModuleExports`, and arc rejects duplicate module names at boot.
185
+ - **SemVer the public export**, not the internals. Your exported function signatures, the
186
+ `ArcModule` shape you return, and the action/schema surface are the contract. Bumping a peer
187
+ floor (e.g. requiring a newer arc) is at least a **minor**; changing an exported signature is
188
+ a **major**.
189
+
190
+ ---
191
+
192
+ ## Release gate
193
+
194
+ Every publish runs a gate — no `--no-verify`, ever. In a workspace, a shared script; in a
195
+ solo package, `prepublishOnly`:
196
+
197
+ ```jsonc
198
+ "scripts": {
199
+ "build": "tsdown",
200
+ "typecheck": "tsc --noEmit",
201
+ "lint": "biome check src/",
202
+ "prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"
203
+ }
204
+ ```
205
+
206
+ For a **monorepo of packages**, add a **peer-skew gate** that fails if any package's devDep
207
+ floor is below its declared peer floor — it's the mechanical guard that stops your suite from
208
+ silently testing against a version older than you promise to support. Full script and workspace
209
+ layout in [references/publishing-workflow.md](references/publishing-workflow.md).
210
+
211
+ **classytic packages** additionally: commit as `classytic-bot[bot]` (per-invocation git env
212
+ vars, never `git config --global`, no `Co-Authored-By`), push via `classytic-push`, publish to
213
+ public npm. See the workspace `CLAUDE.md` and [references/publishing-workflow.md](references/publishing-workflow.md).
214
+
215
+ ---
216
+
217
+ ## Pre-publish checklist
218
+
219
+ - [ ] `dependencies` is empty (or only truly-private bundled helpers)
220
+ - [ ] every arc/fastify/kit dep is a `peerDependency` with a `>=` range
221
+ - [ ] every peer floor is covered by a `^`-pinned devDep at or above it (peer-skew clean)
222
+ - [ ] optional peers are marked `optional: true` and their types don't leak into `dist/*.d.mts`
223
+ - [ ] tsdown `neverBundle: [/^@classytic\//]` + `skipNodeModulesBundle: true`
224
+ - [ ] imports use public subpaths only — no `@classytic/arc/src` or deep `dist` paths
225
+ - [ ] arc has **no** dependency on this package (grep arc for the package name → nothing)
226
+ - [ ] `sideEffects: false`, `files: ["dist"]`, `exports` map with `types`+`default`, `type: "module"`
227
+ - [ ] action schemas shipped as **JSON Schema** (zod stays an optional peer, never required)
228
+ - [ ] `prepublishOnly` gate green (typecheck + lint + test + build), no `--no-verify`
229
+ - [ ] module `name` is namespaced and can't collide across a multi-module host
230
+ - [ ] boots green in a **kit-backed testkit** (real in-memory store, prod-fidelity — not a fake repo)
231
+ - [ ] `.npmrc`: `auto-install-peers=false` + an install-script allowlist for any dep with a `postinstall`
232
+
233
+ ---
234
+
235
+ ## References
236
+
237
+ - [references/package-manifest.md](references/package-manifest.md) — copy-paste `package.json`
238
+ + `tsdown.config.ts`, optional-peer structural-typing pattern, the dependency-field cheat sheet.
239
+ - [references/authoring-modules.md](references/authoring-modules.md) — `defineModule` lifecycle,
240
+ `dependsOn` composition order, `getModuleExports` cross-module wiring, thunk lazy/region packs,
241
+ action-preset and structural-port patterns.
242
+ - [references/publishing-workflow.md](references/publishing-workflow.md) — monorepo layout, the
243
+ peer-skew + prepublish gates, classytic bot-identity release steps, versioning a public export.
@@ -0,0 +1,327 @@
1
+ # Authoring: presets, plugins, and modules
2
+
3
+ The three shapes a package exports, smallest surface first. All import arc through public
4
+ subpaths only (`@classytic/arc/{permissions,scope,types,utils,factory,testing}`).
5
+
6
+ ---
7
+
8
+ ## Shape 1 — Action preset (add verbs to one resource)
9
+
10
+ The smallest reusable unit: a function returning an `actions:` slice the host spreads into a
11
+ resource. No lifecycle, no engine. Storage-agnostic via a **structural repository port** — an
12
+ interface listing only the methods you call, satisfied by any kit repository without importing
13
+ the kit.
14
+
15
+ ```ts
16
+ import type { PermissionCheck } from "@classytic/arc/permissions";
17
+ import { getOrgId, getUserId } from "@classytic/arc/scope";
18
+ import type { ActionDefinition, RequestWithExtras } from "@classytic/arc/types";
19
+ import { createDomainError, NotFoundError } from "@classytic/arc/utils";
20
+
21
+ // Structural port — NOT a kit import. Any kit repo with these methods satisfies it.
22
+ export interface SubjectRepository<TDoc> {
23
+ getById(id: string, o: { organizationId?: string; throwOnNotFound: false; lean: true }): Promise<TDoc | null>;
24
+ update(id: string, patch: Record<string, unknown>, o: { organizationId?: string; lean: true }): Promise<TDoc | null>;
25
+ }
26
+
27
+ export interface WithXConfig<TDoc> {
28
+ readonly repository: SubjectRepository<TDoc>;
29
+ readonly permissions: { readonly act: PermissionCheck };
30
+ // ...host policy injected here: resolvers, side-effect hooks, tenancy mode
31
+ }
32
+
33
+ export function withX<TDoc>(config: WithXConfig<TDoc>): Record<"do_x", ActionDefinition> {
34
+ return {
35
+ do_x: {
36
+ handler: async (id, data, req) => handler(config, id, data, req),
37
+ permissions: config.permissions.act,
38
+ description: "…",
39
+ schema: { type: "object", /* JSON Schema */ additionalProperties: false },
40
+ },
41
+ };
42
+ }
43
+ ```
44
+
45
+ Host composes it:
46
+
47
+ ```ts
48
+ defineResource({
49
+ actions: {
50
+ ...withX({ repository, permissions, /* host policy */ }),
51
+ post: { /* subject-specific verbs stay here */ },
52
+ },
53
+ });
54
+ ```
55
+
56
+ **Rules that make a preset reusable:**
57
+ - Inject **all policy** through config (permissions, resolvers, side-effect hooks). The preset
58
+ contributes *mechanism*; the host owns *policy*.
59
+ - Take a **structural port**, never a concrete kit type. `SubjectRepository<TDoc>` with the two
60
+ methods you use — not `MongooseRepository`.
61
+ - Read tenant/user via `getOrgId(req.scope)` / `getUserId(req.scope)`, never off `req.user`
62
+ directly. Honor a `tenancy: "required" | "off"` config so single-tenant hosts work too.
63
+ - Ship schemas as **JSON Schema**. Map any domain errors to arc's `createDomainError(code, msg,
64
+ status)` so the wire contract stays consistent.
65
+ - Model a **mode/source as a discriminated field, not a boolean flag**. `source: 'literal' |
66
+ 'policy' | 'auto'` beats `useMatrix: boolean`: it's extensible (a third mode is a new enum
67
+ member, not a second conflicting flag), self-documenting, and makes contradictory inputs
68
+ (a literal payload sent with `source: 'policy'`) rejectable instead of silently dropped. If a
69
+ published boolean already exists, keep it as a `@deprecated` alias that bridges to the field
70
+ for one minor, then remove — don't strand consumers on a hard rename.
71
+
72
+ ---
73
+
74
+ ## Shape 2 — Plugin (cross-cutting decorator/hook)
75
+
76
+ An `fp()`-wrapped Fastify plugin — the one documented exception to arc's no-default-exports
77
+ rule. Ship **both** a default (for `app.register(import(...))`) and a named export.
78
+
79
+ ```ts
80
+ import fp from "fastify-plugin";
81
+ import type { FastifyInstance } from "fastify";
82
+
83
+ async function xPlugin(app: FastifyInstance /*, opts */): Promise<void> {
84
+ app.decorate("x", createX(/* ... */));
85
+ // Set response headers at onRequest or preSerialization — NEVER onSend (races Fastify's flush).
86
+ }
87
+
88
+ export const x = fp(xPlugin, { name: "arc-x", fastify: "5.x" });
89
+ export default x; // fp() plugin entries MAY default-export (arc convention exception)
90
+ ```
91
+
92
+ Host registers it in the `plugins:` slot or via `app.register`. If the plugin belongs to a
93
+ domain module, register it in the module's `plugins` lifecycle slot instead (below).
94
+
95
+ ---
96
+
97
+ ## Shape 3 — Module (a whole domain as one value)
98
+
99
+ Use `defineModule` when there's engine state to boot **and** multiple resources to register as
100
+ a unit. The host lists it in `createApp({ modules: [...] })`; arc expands it into the same
101
+ lifecycle phases a hand-wired app uses.
102
+
103
+ ```ts
104
+ import { defineModule, getModuleExports } from "@classytic/arc/factory";
105
+ import type { FastifyInstance } from "fastify";
106
+
107
+ export interface AccountingEngine { post(entry: Entry): Promise<void>; }
108
+ export interface AccountingDeps { permissions: PermissionMatrix; }
109
+
110
+ // Factory takes host deps, returns an ArcModule. TExports inferred from bootstrap's return.
111
+ export const accountingModule = (deps: AccountingDeps) =>
112
+ defineModule({
113
+ name: "accounting", // namespaced, unique across the host — the graph key
114
+ dependsOn: ["ledger"], // composition order (see below), NOT lazy loading
115
+
116
+ plugins: (f) => { // register infra — runs after app plugins(), before bootstraps
117
+ f.register(/* db conn, sse, etc. */);
118
+ },
119
+
120
+ bootstrap: async (f) => { // init engine — its return becomes the PUBLIC EXPORT
121
+ const ledger = getModuleExports<LedgerEngine>(f, "ledger"); // guaranteed live (dependsOn)
122
+ return createAccountingEngine(deps, ledger); // → fastify.arc.modules.accounting
123
+ },
124
+
125
+ resources: (f) => { // factory form runs AFTER all bootstraps — engines are live
126
+ const engine = getModuleExports<AccountingEngine>(f, "accounting");
127
+ return [accountsResource(engine, deps.permissions), entriesResource(engine)];
128
+ },
129
+
130
+ afterResources: (f) => { /* cross-resource event subscriptions the module owns */ },
131
+ onClose: (f) => { /* stop timers, flush outbox, destroy engine */ },
132
+
133
+ errorMappers: [{ // 2.21 — domain error → wire envelope, merged into the
134
+ type: AccountingError, // host's error handler at boot (host mappers keep priority)
135
+ toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
136
+ }],
137
+ });
138
+ ```
139
+
140
+ ### Lifecycle phases (module runs before app-level in each)
141
+
142
+ ```
143
+ plugins = app plugins() → modules' plugins (infra registration)
144
+ bootstrap[] = modules' bootstrap → options.bootstrap (engine init + export)
145
+ resources[] = modules' resources → options.resources (module routes register first)
146
+ afterResources = modules' afterResources → options.afterResources
147
+ onClose = modules' onClose (REVERSE order) → options.onClose (last composed, first closed)
148
+ ```
149
+
150
+ A module's engine is live before app-level bootstrap; app `afterResources` can wire across
151
+ modules; teardown mirrors init in reverse.
152
+
153
+ ### `dependsOn` is composition order, not lazy loading
154
+
155
+ `dependsOn: ["ledger"]` guarantees ledger's `plugins`/`bootstrap` (and thus its public export)
156
+ run **before** this module's — so `getModuleExports(f, "ledger")` is live. It does **not** delay
157
+ *importing* the module. Every module input (including dynamic-import thunks) is resolved up
158
+ front via `Promise.all`, then `dependsOn` orders the *composition*. It's a **stable topological
159
+ sort**: modules with no edge between them keep list order, so adding `dependsOn` never silently
160
+ reorders an unrelated module. Fail-fast at boot on unknown dep, self-reference, and cycles.
161
+
162
+ ### Cross-module wiring — `getModuleExports`, not a container
163
+
164
+ A module's `bootstrap` return value is its **public export**, recorded at
165
+ `fastify.arc.modules[name]`. Later modules read it with the typed accessor — no DI container,
166
+ no proxy. For inferred types without an inline generic, augment the registry once:
167
+
168
+ ```ts
169
+ declare module "@classytic/arc/factory" {
170
+ interface ArcModuleRegistry {
171
+ accounting: AccountingEngine;
172
+ ledger: LedgerEngine;
173
+ }
174
+ }
175
+ // then: getModuleExports(f, "accounting") infers AccountingEngine
176
+ ```
177
+
178
+ `getModuleExports` **throws** (fail-fast) if the name isn't composed, is composed after the
179
+ caller, or returned nothing — all wiring bugs that must surface at boot, not as `undefined`
180
+ downstream.
181
+
182
+ ### Lazy / region packs — thunk of a dynamic import
183
+
184
+ To gate *whether* a module is imported at all (region/tier packs), pass a thunk that returns a
185
+ dynamic import. Only the selected pack enters the bundle path:
186
+
187
+ ```ts
188
+ const taxPack =
189
+ region === "BD"
190
+ ? () => import("@classytic/bd-tax").then((m) => m.createBdTaxModule(deps))
191
+ : () => import("@classytic/us-tax").then((m) => m.createUsTaxModule(deps));
192
+
193
+ await createApp({ modules: [coreModule(deps), taxPack] });
194
+ // only the selected tax package is imported + composed
195
+ ```
196
+
197
+ Thunk = *whether to import*; `dependsOn` = *order of already-selected modules*. Different tools.
198
+
199
+ ### One module per package
200
+
201
+ A package exports **one** `createXModule` factory. To compose several domains, publish several
202
+ packages (or several subpath exports) and let the host list them — don't return an array of
203
+ modules from one factory. The host owns the flat `modules:` list and its ordering; keep that
204
+ control theirs.
205
+
206
+ ### Resource assembly — kernel-typed ports + `mergeResourceConfig` (2.21 standard)
207
+
208
+ Two rules kill the `as never` tax that plagued pre-2.21 module packages:
209
+
210
+ **1. Type the engine port with the KERNEL's own bags.** The kernel is already your peer, so
211
+ indexing its exported engine type adds zero coupling — and its truths (optional sub-module
212
+ members, literal unions) flow into your resource builders:
213
+
214
+ ```ts
215
+ import type { CatalogEngine } from "@classytic/catalog";
216
+
217
+ export interface CatalogEngineLike { // structural — any conforming engine composes
218
+ models: CatalogEngine["models"];
219
+ repositories: CatalogEngine["repositories"];
220
+ }
221
+ ```
222
+
223
+ Never re-declare the bags as `Record<string, unknown>` — that's what forced every
224
+ `createMongooseAdapter(engine.models.X as never, ...)` cast.
225
+
226
+ **2. Merge base config + host seams with `mergeResourceConfig`, not hand-rolled spreads.**
227
+ Expose your per-resource override surface as projections of arc's `ResourceSeams` and let
228
+ arc's slot-aware merge do assembly — arrays CONCAT (host routes append, never clobber),
229
+ plain objects deep-merge, class instances (queryParser, adapter) last-win, `undefined`
230
+ never overwrites, `as const` readonly host tables are accepted:
231
+
232
+ ```ts
233
+ import { defineResource, mergeResourceConfig, type ResourceSeams } from "@classytic/arc";
234
+ import { permissionMatrix } from "@classytic/arc/permissions";
235
+
236
+ export interface ProductSeams { // your public seam names, arc's slot types
237
+ extraRoutes?: ResourceSeams["routes"];
238
+ extraActions?: ResourceSeams["actions"];
239
+ cache?: ResourceSeams["cache"];
240
+ }
241
+
242
+ defineResource(mergeResourceConfig(
243
+ {
244
+ name: "product",
245
+ adapter: createMongooseAdapter({ model: models.Product, repository: repositories.product }),
246
+ permissions: permissionMatrix({ read: deps.permissions.view, write: deps.permissions.manage }),
247
+ },
248
+ { routes: cfg.extraRoutes, actions: cfg.extraActions, cache: cfg.cache },
249
+ ));
250
+ ```
251
+
252
+ The only casts left in a well-formed package are documented boundaries: wire-body narrowing
253
+ in action handlers (`data as { role: PartyRole }` — narrow to the KERNEL's union, not
254
+ `string`) and hydrated-vs-lean id widening at a kernel bridge. Anything else is a smell.
255
+
256
+ ---
257
+
258
+ ## Testing a package
259
+
260
+ Boot a **real arc app** around your export and exercise it over HTTP — that's the only test
261
+ that proves it *composes*. There are two harness layers, and the difference matters:
262
+
263
+ | Harness | Persistence | Fidelity | Home |
264
+ |---|---|---|---|
265
+ | `@classytic/arc/testing` | none — you inject a repo (fake / `Map`-backed) | boot + wiring only | inside arc |
266
+ | a **kit-backed testkit** (e.g. `@classytic/arc-testkit`) | real in-memory store (Mongo memory-server) | **prod-fidelity** | arc-ecosystem |
267
+
268
+ **Why two layers, and why the testkit is kit-specific:** arc's non-negotiable rule is *no DB
269
+ driver imports anywhere in arc*. So `@classytic/arc/testing` can boot the app but cannot give
270
+ you real persistence — it hands you an injected/fake repository. A harness that persists for
271
+ real **must** pick a concrete kit (mongokit + `mongodb-memory-server`, or sqlite in-memory).
272
+ That's not a limitation to design around — it's the point: **a testkit's value is fidelity, so
273
+ it should exercise the module the way it runs in production**, with the same adapter.
274
+
275
+ **Don't "make the testkit DB-agnostic" by defaulting to a fake in-memory repo.** A `Map`-backed
276
+ repo does not reproduce ObjectId-vs-string `_id`, `select`/projection semantics, query-operator
277
+ translation, pagination cursor encoding, unique-index enforcement, or Mongoose strict-mode
278
+ field dropping. A module that ships a real adapter will pass on the fake and break in prod — the
279
+ test gets greener and less truthful. Agnostic *interface*, real *backend*:
280
+
281
+ - **Agnostic seam:** the harness takes a **`TestBackend`** — `setup() → { ctx: { connection,
282
+ uri }, teardown }` — so the store is pluggable, not hard-wired.
283
+ - **Real default, backend lives in the kit:** `@classytic/arc-testkit` is the thin generic core
284
+ (`bootModuleApp(modules, { backend })`); its default backend is `mongoMemoryBackend()` from
285
+ **`@classytic/mongokit/testkit`**, loaded via dynamic `import()` so mongokit +
286
+ `mongodb-memory-server` stay *optional* peers. This is the concrete "adapters/backends live in
287
+ their kits" split — mongokit owns the Mongo lifecycle, arc-testkit owns the arc boot. Add a
288
+ sibling (`@classytic/sqlitekit/testkit` → `sqliteMemoryBackend()`) only when a non-Mongo module
289
+ exists — YAGNI until the second consumer is real.
290
+
291
+ ```ts
292
+ import { bootModuleApp } from "@classytic/arc-testkit";
293
+ import { mongoMemoryBackend } from "@classytic/mongokit/testkit"; // only to override the default
294
+
295
+ const t = await bootModuleApp(({ connection }) => [createOrderModule({ connection, permissions })]);
296
+ // or point at a real cluster: { backend: mongoMemoryBackend({ uri: process.env.ATLAS_TEST_URI }) }
297
+ const res = await t.app.inject({ method: "GET", url: "/orders" });
298
+ await t.close();
299
+ ```
300
+
301
+ **Install contract (the part that bites on adoption):** because the harness declares its DB deps
302
+ as *optional peers*, the consumer installs all three together —
303
+
304
+ ```bash
305
+ npm i -D @classytic/arc-testkit @classytic/mongokit mongodb-memory-server
306
+ ```
307
+
308
+ Miss `mongodb-memory-server` and the default backend throws at boot; pin `@classytic/mongokit`
309
+ below the version that first shipped `/testkit` and the subpath won't resolve (a floating `^`
310
+ range won't upgrade past a lockfile pin on its own — bump the devDep floor). This is the tradeoff
311
+ of *not* bundling: the harness stays out of production, but the consumer owns the dev install.
312
+
313
+ Whichever layer you use, cover these edges:
314
+
315
+ - Boot a minimal app, register your preset/module, hit the routes with `app.inject`.
316
+ - Test **both tenancy modes** (`required` and `off`) if you support them.
317
+ - Avoid field names arc's query parser reserves (`page`, `limit`, `cursor`, `after`, `sort`,
318
+ `search`, `select`, `populate`) — a filter field colliding with one is silently unfilterable.
319
+ Arc emits a `filter-field-reserved-name` boot diagnostic; heed it. **This class of bug only
320
+ surfaces on a real backend** — another reason the fidelity harness earns its keep.
321
+ - Pin devDeps at or above every peer floor so the suite exercises the version you promise.
322
+
323
+ **Shared harness pattern:** the harness is published once — `@classytic/arc-testkit` (generic
324
+ core, in the ecosystem repo, *not* a per-domain monorepo) + `@classytic/mongokit/testkit` (the
325
+ Mongo backend, in the kit) — so every module author and third party reuses it. Depend on it via
326
+ `devDependencies` (a version range from the registry; `workspace:*` only inside its own monorepo).
327
+ "If your module boots green in the testkit, it composes" is the contract.