@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.
- package/dist/{BaseController-D-MHZ27_.mjs → BaseController-C6FlRWxF.mjs} +2 -2
- package/dist/{EventTransport-IEn7Tn-Q.d.mts → EventTransport-QmY7A258.d.mts} +0 -1
- package/dist/{QueryCache-TRfXau3W.d.mts → QueryCache-Z1B38koh.d.mts} +0 -1
- package/dist/{actionPermissions-C9RMiIpc.mjs → actionPermissions-ze70xXJi.mjs} +1 -1
- package/dist/audit/index.d.mts +9 -5
- package/dist/auth/audit.d.mts +7 -4
- package/dist/auth/audit.mjs +5 -0
- package/dist/auth/index.d.mts +35 -14
- package/dist/auth/index.mjs +6 -3
- package/dist/auth/redis-session.d.mts +1 -2
- package/dist/cache/index.d.mts +2 -3
- package/dist/cli/commands/describe.d.mts +1 -2
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/context/index.d.mts +2 -2
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -4
- package/dist/{core-CWhyGkiM.mjs → core-b6fgFgnd.mjs} +129 -17
- package/dist/{createActionRouter-HlQso0jO.mjs → createActionRouter-h1cEEaEX.mjs} +10 -6
- package/dist/{createAggregationRouter-5LzPzgw1.mjs → createAggregationRouter-B1C0kvTL.mjs} +3 -3
- package/dist/{createApp-D6uwzCKu.mjs → createApp-Ci7yG5gv.mjs} +390 -192
- package/dist/discovery/index.d.mts +0 -1
- package/dist/docs/index.d.mts +1 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{elevation-xGUBq6p_.d.mts → elevation-C9s5hFP_.d.mts} +0 -1
- package/dist/encryption/index.d.mts +2 -144
- package/dist/encryption/index.mjs +9 -5
- package/dist/{errorHandler-Dxf6P_kV.mjs → errorHandler-BTzXSwIg.mjs} +24 -3
- package/dist/{errorHandler-BtUUDPEa.d.mts → errorHandler-C6yv4ZTW.d.mts} +13 -1
- package/dist/{errors-DOHkjQlD.d.mts → errors-DHcHVAgk.d.mts} +0 -1
- package/dist/{eventPlugin-DbBFd3x_.d.mts → eventPlugin-A6EbthXR.d.mts} +10 -6
- package/dist/events/index.d.mts +9 -6
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -2
- package/dist/factory/index.d.mts +30 -9
- package/dist/factory/index.mjs +35 -4
- package/dist/{fastify-augmentation-CCQJEDgh.d.mts → fastify-augmentation-9_fuetB4.d.mts} +2 -3
- package/dist/fields-D3lhgzhD.d.mts +121 -0
- package/dist/health-ScyjIVo5.mjs +202 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +4 -3
- package/dist/idempotency/index.mjs +2 -0
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{types-BHMKlMBU.d.mts → index-BEu4wjbL.d.mts} +241 -80
- package/dist/{index-DUa8WHz3.d.mts → index-D8MKYGDa.d.mts} +88 -7
- package/dist/{index-_--GyzcO.d.mts → index-DbtgCOCj.d.mts} +1324 -1228
- package/dist/{index-CdFkIMEd.d.mts → index-ogIHvOjo.d.mts} +36 -7
- package/dist/index.d.mts +8 -7
- package/dist/index.mjs +6 -6
- package/dist/{init-YH_nslQR.mjs → init-DleK3p9f.mjs} +272 -139
- package/dist/integrations/event-gateway.d.mts +2 -3
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/jobs.d.mts +0 -1
- package/dist/integrations/mcp/index.d.mts +6 -7
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -2
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +2 -2
- package/dist/integrations/webhooks.d.mts +8 -5
- package/dist/integrations/webhooks.mjs +9 -5
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -2
- package/dist/integrations/websocket-redis.d.mts +1 -2
- package/dist/integrations/websocket.d.mts +2 -2
- package/dist/jsonBody-BlzAoGCe.mjs +43 -0
- package/dist/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
- package/dist/logger/index.d.mts +26 -1
- package/dist/logger/index.mjs +39 -1
- package/dist/{mcpPlugin-CuW6L_uD.mjs → mcpPlugin-ByA2tf6D.mjs} +8 -4
- package/dist/middleware/index.d.mts +1 -2
- package/dist/{module-JYH9V0I8.d.mts → module-B4u10iOJ.d.mts} +71 -1
- package/dist/{openapi-Dq6RbEGK.mjs → openapi-BRpMZD7C.mjs} +3 -3
- package/dist/permissions/index.d.mts +4 -3
- package/dist/permissions/index.mjs +2 -2
- package/dist/{permissions-CgbOLJZG.mjs → permissions-BJMuKMl7.mjs} +33 -1
- package/dist/pipeline/index.d.mts +1 -2
- package/dist/plugins/index.d.mts +96 -7
- package/dist/plugins/index.mjs +86 -268
- package/dist/plugins/response-cache.d.mts +5 -3
- package/dist/plugins/response-cache.mjs +1 -0
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +7 -4
- package/dist/presets/filesUpload.d.mts +4 -5
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +1 -2
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -2
- package/dist/presets/search.d.mts +2 -3
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-DWsYQdbV.mjs → presets-gw0ciIUD.mjs} +1 -1
- package/dist/{pushref-registry-DnCqL3aa.d.mts → pushref-registry-BbcnpNTm.d.mts} +4 -2
- package/dist/{queryCachePlugin-xzFyKAjT.d.mts → queryCachePlugin-CfYWq-Ak.d.mts} +1 -2
- package/dist/rateLimitKey-BoWRH3e7.mjs +14 -0
- package/dist/{redis-C3W0qbz8.d.mts → redis-UDtLaNj6.d.mts} +0 -1
- package/dist/{redis-stream-DsP1vqZB.d.mts → redis-stream-iVR2l7aS.d.mts} +1 -2
- package/dist/{registerRoute-DGSREwPw.mjs → registerRoute-xHc42fwB.mjs} +38 -18
- package/dist/registry/index.d.mts +2 -2
- package/dist/requestId-5BhTVzBv.mjs +113 -0
- package/dist/schemaConverter-Ds5reSGs.mjs +168 -0
- package/dist/{schemaIR-ajetw3Fv.mjs → schemaIR-B-6SPe7g.mjs} +2 -2
- package/dist/schemas/index.d.mts +0 -1
- package/dist/scim/index.d.mts +0 -1
- package/dist/scim/index.mjs +6 -7
- package/dist/scope/index.d.mts +2 -3
- package/dist/scope/index.mjs +2 -76
- package/dist/scope-k15RXfcq.mjs +64 -0
- package/dist/{sessionManager-BXG-HGwG.d.mts → sessionManager-BHOM3N_0.d.mts} +10 -6
- package/dist/{storage-Co_PQpeQ.d.mts → storage-DLUZ04kK.d.mts} +8 -4
- package/dist/testing/index.d.mts +83 -4
- package/dist/testing/index.mjs +74 -4
- package/dist/testing/storageContract.d.mts +1 -2
- package/dist/{tracing-D8jlGXP7.d.mts → tracing-CsGKQJGg.d.mts} +0 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-C9b2HR5W.d.mts → types-Bc42CUyk.d.mts} +1 -2
- package/dist/types-Cc91nXuA.d.mts +132 -0
- package/dist/{fields-C7ahdGYf.d.mts → types-Csxga7d3.d.mts} +2 -123
- package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
- package/dist/usage/index.d.mts +142 -0
- package/dist/usage/index.mjs +0 -0
- package/dist/utils/index.d.mts +220 -144
- package/dist/utils/index.mjs +3 -3
- package/dist/{utils-ChjYQpjn.mjs → utils-FhJ88TJE.mjs} +129 -39
- package/dist/{versioning-D_eBCfNr.d.mts → versioning-DLwklPlG.d.mts} +1 -7
- package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
- package/llms.txt +39 -0
- package/package.json +453 -447
- package/skills/arc/SKILL.md +14 -8
- package/skills/arc/references/api-reference.md +34 -0
- package/skills/arc/references/auth.md +21 -4
- package/skills/arc/references/mcp.md +1 -1
- package/skills/arc/references/production.md +44 -2
- package/skills/arc-code-review/SKILL.md +1 -1
- package/skills/arc-code-review/references/anti-patterns.md +71 -18
- package/skills/arc-code-review/references/arc-cheatsheet.md +15 -2
- package/skills/arc-code-review/references/migration-recipes.md +7 -7
- package/skills/arc-code-review/references/mongokit-migration.md +4 -3
- package/skills/arc-module-publishing/SKILL.md +243 -0
- package/skills/arc-module-publishing/references/authoring-modules.md +327 -0
- package/skills/arc-module-publishing/references/package-manifest.md +157 -0
- package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
- package/dist/preloadResources-CHw3_oA_.mjs +0 -45
- 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.
|