@classytic/arc 2.20.0 → 2.21.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-BnLYgE9A.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 +7 -14
- package/dist/auth/index.mjs +1 -1
- 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-DZ6Xe1jR.mjs} +65 -10
- package/dist/{createActionRouter-HlQso0jO.mjs → createActionRouter-BWMDW-dL.mjs} +2 -2
- package/dist/{createAggregationRouter-5LzPzgw1.mjs → createAggregationRouter-l5mNdYrZ.mjs} +1 -1
- package/dist/{createApp-D6uwzCKu.mjs → createApp-Bi61XxqU.mjs} +149 -137
- 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/{errorHandler-BtUUDPEa.d.mts → errorHandler-B0e-bq-O.d.mts} +0 -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 +4 -4
- package/dist/factory/index.mjs +2 -2
- package/dist/{fastify-augmentation-CCQJEDgh.d.mts → fastify-augmentation-DafigGQp.d.mts} +2 -3
- package/dist/{fields-C7ahdGYf.d.mts → fields-BGqrAa_u.d.mts} +1 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +4 -3
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-DUa8WHz3.d.mts → index-B5N-NaHs.d.mts} +88 -7
- package/dist/{index-CdFkIMEd.d.mts → index-B5PoNbfu.d.mts} +36 -7
- package/dist/{index-_--GyzcO.d.mts → index-Dtrj6XZk.d.mts} +62 -28
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +6 -6
- package/dist/{init-YH_nslQR.mjs → init-0YhHkXGk.mjs} +154 -137
- 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 +0 -1
- 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/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
- package/dist/{mcpPlugin-CuW6L_uD.mjs → mcpPlugin-B9XjjNVG.mjs} +3 -3
- package/dist/middleware/index.d.mts +1 -2
- package/dist/{module-JYH9V0I8.d.mts → module-WeuU5jfd.d.mts} +46 -1
- package/dist/{openapi-Dq6RbEGK.mjs → openapi-Kyod_di6.mjs} +2 -2
- package/dist/permissions/index.d.mts +3 -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 +79 -7
- package/dist/plugins/index.mjs +88 -1
- package/dist/plugins/response-cache.d.mts +5 -3
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- 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/{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-oGAl0oA8.mjs} +1 -1
- package/dist/registry/index.d.mts +2 -2
- package/dist/schemas/index.d.mts +0 -1
- package/dist/scim/index.d.mts +0 -1
- package/dist/scope/index.d.mts +2 -3
- 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 +2 -3
- package/dist/testing/index.mjs +5 -2
- 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-Cc91nXuA.d.mts +132 -0
- package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
- package/dist/{types-C9b2HR5W.d.mts → types-DRj-Ut4n.d.mts} +1 -2
- package/dist/{types-BHMKlMBU.d.mts → types-DpxoNX5v.d.mts} +61 -21
- package/dist/utils/index.d.mts +182 -128
- package/dist/utils/index.mjs +2 -2
- package/dist/{utils-ChjYQpjn.mjs → utils-CO8a3B_1.mjs} +70 -26
- package/dist/{versioning-D_eBCfNr.d.mts → versioning-DQ4mCvg0.d.mts} +1 -2
- package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
- package/package.json +7 -7
- package/skills/arc-module-publishing/SKILL.md +242 -0
- package/skills/arc-module-publishing/references/authoring-modules.md +272 -0
- package/skills/arc-module-publishing/references/package-manifest.md +155 -0
- package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
```
|
|
134
|
+
|
|
135
|
+
### Lifecycle phases (module runs before app-level in each)
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
plugins = app plugins() → modules' plugins (infra registration)
|
|
139
|
+
bootstrap[] = modules' bootstrap → options.bootstrap (engine init + export)
|
|
140
|
+
resources[] = modules' resources → options.resources (module routes register first)
|
|
141
|
+
afterResources = modules' afterResources → options.afterResources
|
|
142
|
+
onClose = modules' onClose (REVERSE order) → options.onClose (last composed, first closed)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A module's engine is live before app-level bootstrap; app `afterResources` can wire across
|
|
146
|
+
modules; teardown mirrors init in reverse.
|
|
147
|
+
|
|
148
|
+
### `dependsOn` is composition order, not lazy loading
|
|
149
|
+
|
|
150
|
+
`dependsOn: ["ledger"]` guarantees ledger's `plugins`/`bootstrap` (and thus its public export)
|
|
151
|
+
run **before** this module's — so `getModuleExports(f, "ledger")` is live. It does **not** delay
|
|
152
|
+
*importing* the module. Every module input (including dynamic-import thunks) is resolved up
|
|
153
|
+
front via `Promise.all`, then `dependsOn` orders the *composition*. It's a **stable topological
|
|
154
|
+
sort**: modules with no edge between them keep list order, so adding `dependsOn` never silently
|
|
155
|
+
reorders an unrelated module. Fail-fast at boot on unknown dep, self-reference, and cycles.
|
|
156
|
+
|
|
157
|
+
### Cross-module wiring — `getModuleExports`, not a container
|
|
158
|
+
|
|
159
|
+
A module's `bootstrap` return value is its **public export**, recorded at
|
|
160
|
+
`fastify.arc.modules[name]`. Later modules read it with the typed accessor — no DI container,
|
|
161
|
+
no proxy. For inferred types without an inline generic, augment the registry once:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
declare module "@classytic/arc/factory" {
|
|
165
|
+
interface ArcModuleRegistry {
|
|
166
|
+
accounting: AccountingEngine;
|
|
167
|
+
ledger: LedgerEngine;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// then: getModuleExports(f, "accounting") infers AccountingEngine
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`getModuleExports` **throws** (fail-fast) if the name isn't composed, is composed after the
|
|
174
|
+
caller, or returned nothing — all wiring bugs that must surface at boot, not as `undefined`
|
|
175
|
+
downstream.
|
|
176
|
+
|
|
177
|
+
### Lazy / region packs — thunk of a dynamic import
|
|
178
|
+
|
|
179
|
+
To gate *whether* a module is imported at all (region/tier packs), pass a thunk that returns a
|
|
180
|
+
dynamic import. Only the selected pack enters the bundle path:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const taxPack =
|
|
184
|
+
region === "BD"
|
|
185
|
+
? () => import("@classytic/bd-tax").then((m) => m.createBdTaxModule(deps))
|
|
186
|
+
: () => import("@classytic/us-tax").then((m) => m.createUsTaxModule(deps));
|
|
187
|
+
|
|
188
|
+
await createApp({ modules: [coreModule(deps), taxPack] });
|
|
189
|
+
// only the selected tax package is imported + composed
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Thunk = *whether to import*; `dependsOn` = *order of already-selected modules*. Different tools.
|
|
193
|
+
|
|
194
|
+
### One module per package
|
|
195
|
+
|
|
196
|
+
A package exports **one** `createXModule` factory. To compose several domains, publish several
|
|
197
|
+
packages (or several subpath exports) and let the host list them — don't return an array of
|
|
198
|
+
modules from one factory. The host owns the flat `modules:` list and its ordering; keep that
|
|
199
|
+
control theirs.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Testing a package
|
|
204
|
+
|
|
205
|
+
Boot a **real arc app** around your export and exercise it over HTTP — that's the only test
|
|
206
|
+
that proves it *composes*. There are two harness layers, and the difference matters:
|
|
207
|
+
|
|
208
|
+
| Harness | Persistence | Fidelity | Home |
|
|
209
|
+
|---|---|---|---|
|
|
210
|
+
| `@classytic/arc/testing` | none — you inject a repo (fake / `Map`-backed) | boot + wiring only | inside arc |
|
|
211
|
+
| a **kit-backed testkit** (e.g. `@classytic/arc-testkit`) | real in-memory store (Mongo memory-server) | **prod-fidelity** | arc-ecosystem |
|
|
212
|
+
|
|
213
|
+
**Why two layers, and why the testkit is kit-specific:** arc's non-negotiable rule is *no DB
|
|
214
|
+
driver imports anywhere in arc*. So `@classytic/arc/testing` can boot the app but cannot give
|
|
215
|
+
you real persistence — it hands you an injected/fake repository. A harness that persists for
|
|
216
|
+
real **must** pick a concrete kit (mongokit + `mongodb-memory-server`, or sqlite in-memory).
|
|
217
|
+
That's not a limitation to design around — it's the point: **a testkit's value is fidelity, so
|
|
218
|
+
it should exercise the module the way it runs in production**, with the same adapter.
|
|
219
|
+
|
|
220
|
+
**Don't "make the testkit DB-agnostic" by defaulting to a fake in-memory repo.** A `Map`-backed
|
|
221
|
+
repo does not reproduce ObjectId-vs-string `_id`, `select`/projection semantics, query-operator
|
|
222
|
+
translation, pagination cursor encoding, unique-index enforcement, or Mongoose strict-mode
|
|
223
|
+
field dropping. A module that ships a real adapter will pass on the fake and break in prod — the
|
|
224
|
+
test gets greener and less truthful. Agnostic *interface*, real *backend*:
|
|
225
|
+
|
|
226
|
+
- **Agnostic seam:** the harness takes a **`TestBackend`** — `setup() → { ctx: { connection,
|
|
227
|
+
uri }, teardown }` — so the store is pluggable, not hard-wired.
|
|
228
|
+
- **Real default, backend lives in the kit:** `@classytic/arc-testkit` is the thin generic core
|
|
229
|
+
(`bootModuleApp(modules, { backend })`); its default backend is `mongoMemoryBackend()` from
|
|
230
|
+
**`@classytic/mongokit/testkit`**, loaded via dynamic `import()` so mongokit +
|
|
231
|
+
`mongodb-memory-server` stay *optional* peers. This is the concrete "adapters/backends live in
|
|
232
|
+
their kits" split — mongokit owns the Mongo lifecycle, arc-testkit owns the arc boot. Add a
|
|
233
|
+
sibling (`@classytic/sqlitekit/testkit` → `sqliteMemoryBackend()`) only when a non-Mongo module
|
|
234
|
+
exists — YAGNI until the second consumer is real.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { bootModuleApp } from "@classytic/arc-testkit";
|
|
238
|
+
import { mongoMemoryBackend } from "@classytic/mongokit/testkit"; // only to override the default
|
|
239
|
+
|
|
240
|
+
const t = await bootModuleApp(({ connection }) => [createOrderModule({ connection, permissions })]);
|
|
241
|
+
// or point at a real cluster: { backend: mongoMemoryBackend({ uri: process.env.ATLAS_TEST_URI }) }
|
|
242
|
+
const res = await t.app.inject({ method: "GET", url: "/orders" });
|
|
243
|
+
await t.close();
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Install contract (the part that bites on adoption):** because the harness declares its DB deps
|
|
247
|
+
as *optional peers*, the consumer installs all three together —
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
npm i -D @classytic/arc-testkit @classytic/mongokit mongodb-memory-server
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Miss `mongodb-memory-server` and the default backend throws at boot; pin `@classytic/mongokit`
|
|
254
|
+
below the version that first shipped `/testkit` and the subpath won't resolve (a floating `^`
|
|
255
|
+
range won't upgrade past a lockfile pin on its own — bump the devDep floor). This is the tradeoff
|
|
256
|
+
of *not* bundling: the harness stays out of production, but the consumer owns the dev install.
|
|
257
|
+
|
|
258
|
+
Whichever layer you use, cover these edges:
|
|
259
|
+
|
|
260
|
+
- Boot a minimal app, register your preset/module, hit the routes with `app.inject`.
|
|
261
|
+
- Test **both tenancy modes** (`required` and `off`) if you support them.
|
|
262
|
+
- Avoid field names arc's query parser reserves (`page`, `limit`, `cursor`, `after`, `sort`,
|
|
263
|
+
`search`, `select`, `populate`) — a filter field colliding with one is silently unfilterable.
|
|
264
|
+
Arc emits a `filter-field-reserved-name` boot diagnostic; heed it. **This class of bug only
|
|
265
|
+
surfaces on a real backend** — another reason the fidelity harness earns its keep.
|
|
266
|
+
- Pin devDeps at or above every peer floor so the suite exercises the version you promise.
|
|
267
|
+
|
|
268
|
+
**Shared harness pattern:** the harness is published once — `@classytic/arc-testkit` (generic
|
|
269
|
+
core, in the ecosystem repo, *not* a per-domain monorepo) + `@classytic/mongokit/testkit` (the
|
|
270
|
+
Mongo backend, in the kit) — so every module author and third party reuses it. Depend on it via
|
|
271
|
+
`devDependencies` (a version range from the registry; `workspace:*` only inside its own monorepo).
|
|
272
|
+
"If your module boots green in the testkit, it composes" is the contract.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# package.json + tsdown reference
|
|
2
|
+
|
|
3
|
+
Copy-paste manifest for an arc ecosystem package, plus the dependency-field rules and the
|
|
4
|
+
optional-peer typing pattern. Values below are the real shapes shipped by
|
|
5
|
+
`@classytic/arc-approval` — adapt names, keep the structure.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## package.json
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
{
|
|
13
|
+
"name": "@classytic/arc-<domain>",
|
|
14
|
+
"version": "0.1.0",
|
|
15
|
+
"description": "<one line — what this contributes to an arc host>",
|
|
16
|
+
"type": "module",
|
|
17
|
+
|
|
18
|
+
// Public entry. Add subpaths ("./adapter", "./testing") the same way if you split surfaces.
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.mts",
|
|
22
|
+
"default": "./dist/index.mjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/index.mjs",
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
|
|
28
|
+
"sideEffects": false, // pure ESM, tree-shakable — no import-for-effect modules
|
|
29
|
+
"files": ["dist"], // ship only build output; never src/, tests/, configs
|
|
30
|
+
"publishConfig": { "access": "public" },
|
|
31
|
+
"engines": { "node": ">=22" },
|
|
32
|
+
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsdown",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint": "biome check src/",
|
|
37
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// CONTRACTS — what the host must provide. `>=`, never `^`.
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@classytic/arc": ">=2.20.0",
|
|
43
|
+
"@classytic/primitives": ">=0.9.0"
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Optional peers: declared here AND marked optional below. Host installs only if used.
|
|
47
|
+
// "peerDependencies": { ..., "zod": ">=3.23.0", "ioredis": ">=5.0.0" },
|
|
48
|
+
// "peerDependenciesMeta": {
|
|
49
|
+
// "zod": { "optional": true },
|
|
50
|
+
// "ioredis": { "optional": true }
|
|
51
|
+
// },
|
|
52
|
+
|
|
53
|
+
// TEST versions — pin with ^, floor >= each peer floor. Not shipped.
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@classytic/arc": "^2.20.0",
|
|
56
|
+
"@classytic/primitives": "^0.9.1",
|
|
57
|
+
"@biomejs/biome": "^2.5.2",
|
|
58
|
+
"@types/node": "^22.19.21",
|
|
59
|
+
"tsdown": "^0.22.5",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vitest": "^4.1.9"
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
"keywords": ["arc", "fastify", "<domain>", "classytic"],
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"author": "Classytic",
|
|
67
|
+
"repository": { "type": "git", "url": "https://github.com/classytic/arc-ecosystem.git", "directory": "packages/arc-<domain>" }
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
In an **npm workspace**, devDeps common to all packages hoist to the root `package.json`; a
|
|
72
|
+
package only lists devDeps unique to it. The peer-skew gate reads root + package devDeps
|
|
73
|
+
together (see publishing-workflow.md).
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Dependency-field cheat sheet
|
|
78
|
+
|
|
79
|
+
| Field | What goes here | Range style | Why |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| `dependencies` | (usually nothing) truly-private bundled helpers only | `^` | Ships with you; host can't override. Almost never needed. |
|
|
82
|
+
| `peerDependencies` | arc, fastify, primitives, kits, zod, ioredis | **`>=`** | A contract/floor. Host resolves the real version; don't block their upgrades. |
|
|
83
|
+
| `peerDependenciesMeta` | mark optional peers `{ optional: true }` | — | Host installs only if they use that path. |
|
|
84
|
+
| `devDependencies` | concrete versions your tests run against | `^` | The version you actually exercise; floor must be `>=` peer floor. |
|
|
85
|
+
|
|
86
|
+
**The trap:** putting `@classytic/arc` or `fastify` in `dependencies`, or writing a peer as
|
|
87
|
+
`^2.20.0`. Both force the host into *your* version choice. A published arc package makes as
|
|
88
|
+
few decisions for the host as possible — it states floors and gets out of the way.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## tsdown.config.ts
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { defineConfig } from "tsdown";
|
|
96
|
+
|
|
97
|
+
export default defineConfig({
|
|
98
|
+
entry: ["src/index.ts"], // add entries for extra subpaths: ["src/index.ts", "src/adapter.ts"]
|
|
99
|
+
format: ["esm"], // ESM only — arc is ESM only
|
|
100
|
+
dts: true, // emit .d.mts
|
|
101
|
+
sourcemap: false,
|
|
102
|
+
clean: true,
|
|
103
|
+
treeshake: true,
|
|
104
|
+
target: "node22",
|
|
105
|
+
outDir: "dist",
|
|
106
|
+
deps: {
|
|
107
|
+
// Bundle NOTHING — same never-bundle contract as arc's own tsdown config.
|
|
108
|
+
skipNodeModulesBundle: true,
|
|
109
|
+
neverBundle: [/^@classytic\//],
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Why bundle nothing: if you inline `@classytic/arc` (or primitives, or a kit), the host ends up
|
|
115
|
+
with two copies — yours frozen in `dist/`, theirs in `node_modules`. Arc's plugin registry,
|
|
116
|
+
event transport, and scope decorators are identity-sensitive; two copies register twice and
|
|
117
|
+
diverge. `neverBundle: [/^@classytic\//]` guarantees the host's single installed copy is the
|
|
118
|
+
only one at runtime.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Optional peers: keep their types out of your public surface
|
|
123
|
+
|
|
124
|
+
If a peer is optional (a host might not install it), its types **must not** appear in your
|
|
125
|
+
published `.d.mts` — otherwise a host without it gets a compile error importing your package.
|
|
126
|
+
Use structural typing: describe the shape you need with a local interface, don't import the
|
|
127
|
+
dependency's types into the public boundary.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// ❌ leaks zod into dist/index.d.mts — breaks hosts that didn't install zod
|
|
131
|
+
import type { ZodType } from "zod";
|
|
132
|
+
export function withValidation(schema: ZodType) { /* ... */ }
|
|
133
|
+
|
|
134
|
+
// ✅ structural: the public signature owes nothing to zod's package
|
|
135
|
+
export interface StandardSchemaLike<T> {
|
|
136
|
+
readonly "~standard": { validate: (v: unknown) => { value: T } | { issues: unknown[] } };
|
|
137
|
+
}
|
|
138
|
+
export function withValidation<T>(schema: StandardSchemaLike<T>) { /* ... */ }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
For **shipped action/route schemas, use JSON Schema**, not zod objects. Arc converts JSON
|
|
142
|
+
Schema natively; zod stays an *optional* convenience peer for hosts that prefer it. A package
|
|
143
|
+
that hard-requires zod for its schemas has made zod a non-optional dependency by the back door.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
schema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
required: ["stepId", "decision"],
|
|
149
|
+
properties: {
|
|
150
|
+
stepId: { type: "string", minLength: 1 },
|
|
151
|
+
decision: { type: "string", enum: ["approved", "rejected"] },
|
|
152
|
+
},
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
}
|
|
155
|
+
```
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Publishing workflow
|
|
2
|
+
|
|
3
|
+
Monorepo layout, the release gates, and the classytic bot-identity publish steps.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Monorepo layout (npm workspaces)
|
|
8
|
+
|
|
9
|
+
Prefer **one workspace holding many small packages** over one repo per package — they release
|
|
10
|
+
together and share tooling. The `@classytic/arc-ecosystem` layout:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
arc-ecosystem/
|
|
14
|
+
package.json # workspaces: ["packages/*"], shared devDeps, verify script
|
|
15
|
+
scripts/
|
|
16
|
+
check-peer-skew.mjs # gate: every peer floor covered by a devDep floor
|
|
17
|
+
prepublish-gate.mjs # per-package publish gate
|
|
18
|
+
packages/
|
|
19
|
+
arc-approval/ # @classytic/arc-approval — one package, one surface
|
|
20
|
+
package.json
|
|
21
|
+
tsdown.config.ts
|
|
22
|
+
src/index.ts
|
|
23
|
+
arc-involvement/
|
|
24
|
+
arc-notifications/
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Root `package.json` scripts:
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
{
|
|
31
|
+
"workspaces": ["packages/*"],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "npm run build --workspaces",
|
|
34
|
+
"typecheck": "npm run typecheck --workspaces",
|
|
35
|
+
"lint": "biome check packages/",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"check:peer-skew": "node scripts/check-peer-skew.mjs",
|
|
38
|
+
"verify": "npm run check:peer-skew && npm run typecheck && npm run lint && npm run test && npm run build"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Shared devDeps (arc, primitives, biome, tsdown, typescript, vitest, fastify, @types/node) live
|
|
44
|
+
in the **root** `package.json` so every package tests against one aligned set. During
|
|
45
|
+
pre-publish development the root can carry `"@classytic/arc": "file:../arc"` to test against
|
|
46
|
+
unreleased arc; **switch it to the published range (`"^2.20.0"`) once arc ships** and reinstall
|
|
47
|
+
so packages resolve arc from the registry tarball, not the local symlink.
|
|
48
|
+
|
|
49
|
+
**pnpm + turborepo + changesets** is the heavier-duty alternative to npm workspaces once the
|
|
50
|
+
package count grows: turbo gives cached, topo-ordered builds (`dependsOn: ["^build"]`);
|
|
51
|
+
changesets drive versioning + changelogs (`access: "public"`, `updateInternalDependencies:
|
|
52
|
+
"patch"`). Wire the peer-skew gate into the release path so it can't be skipped — e.g.
|
|
53
|
+
`"release": "pnpm run check:peer-skew && turbo typecheck test build && changeset publish"`.
|
|
54
|
+
Local workspace links use `workspace:*` (pnpm) instead of `file:`; the peer-skew gate treats
|
|
55
|
+
`workspace:` / `link:` / `file:` as covering any floor (the checked-out source *is* the version
|
|
56
|
+
under development).
|
|
57
|
+
|
|
58
|
+
### Supply-chain hygiene (.npmrc / pnpm-workspace.yaml)
|
|
59
|
+
|
|
60
|
+
Belt-and-suspenders settings worth adopting for a published ecosystem:
|
|
61
|
+
|
|
62
|
+
```ini
|
|
63
|
+
# .npmrc
|
|
64
|
+
auto-install-peers=false # peers are the HOST's duty — never silently fetch them
|
|
65
|
+
save-workspace-protocol=rolled
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
# pnpm-workspace.yaml
|
|
70
|
+
onlyBuiltDependencies: # allowlist packages permitted to run install scripts
|
|
71
|
+
- esbuild
|
|
72
|
+
- mongodb-memory-server
|
|
73
|
+
minimumReleaseAgeExclude: # cooldown on brand-new registry versions, with explicit escapes
|
|
74
|
+
- '@classytic/repo-core@0.7.0'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `auto-install-peers=false` enforces the contract mechanically: if a host forgets a peer, they
|
|
78
|
+
get an error, not a silently-resolved wrong version. It also stops your *dev* environment from
|
|
79
|
+
masking a missing peer declaration.
|
|
80
|
+
- `onlyBuiltDependencies` is a lifecycle-script allowlist — nothing runs `postinstall` unless you
|
|
81
|
+
named it. A cheap, real guard against a compromised transitive dep executing on `pnpm install`.
|
|
82
|
+
- A `minimumReleaseAge` cooldown blunts the "malicious version published minutes ago" attack;
|
|
83
|
+
the `Exclude` list is your escape hatch for versions you've vetted.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Gate 1 — peer-skew (monorepo)
|
|
88
|
+
|
|
89
|
+
`scripts/check-peer-skew.mjs` fails the build if any package declares a peer floor higher than
|
|
90
|
+
the devDep floor it actually tests against. Without it, a package can claim `">=2.20.0"` while
|
|
91
|
+
its suite runs against 2.18 — a lie that ships. The gate reads root + package devDeps together
|
|
92
|
+
(`file:` links count as covering any floor, since the link *is* the version under development).
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
// Core check, per package, per peer:
|
|
96
|
+
// devFloor = floorOf(devDeps[name]) // from root + package devDeps merged
|
|
97
|
+
// peerFloor = floorOf(peers[name])
|
|
98
|
+
// if (devFloor < peerFloor) FAIL // suite tests below what you promise
|
|
99
|
+
// Optional peers (peerDependenciesMeta.optional) with no devDep are allowed to be absent.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run it first in `verify` so a skew fails fast before the slower typecheck/test/build.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Gate 2 — prepublish (per package)
|
|
107
|
+
|
|
108
|
+
No package publishes without a green gate. No `--no-verify`, ever.
|
|
109
|
+
|
|
110
|
+
- **Solo package:** `"prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"`.
|
|
111
|
+
- **Workspace:** a shared `scripts/prepublish-gate.mjs` invoked from each package's
|
|
112
|
+
`prepublishOnly`, running the full workspace `verify` (peer-skew + typecheck + lint + test +
|
|
113
|
+
build) so no package ships against a skewed or broken tree.
|
|
114
|
+
|
|
115
|
+
The gate is the contract with consumers: if it's green, the published `dist/` was typechecked,
|
|
116
|
+
linted, tested, and built from the source in the same commit.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Release — classytic packages (bot identity)
|
|
121
|
+
|
|
122
|
+
Every commit in a classytic package is authored as **`classytic-bot[bot]`** — the maintainer
|
|
123
|
+
identity, never an individual. **Per-invocation git env vars only; never `git config
|
|
124
|
+
--global`; no `Co-Authored-By` trailers.**
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
GIT_AUTHOR_NAME="classytic-bot[bot]" \
|
|
128
|
+
GIT_AUTHOR_EMAIL="278929599+classytic-bot[bot]@users.noreply.github.com" \
|
|
129
|
+
GIT_COMMITTER_NAME="classytic-bot[bot]" \
|
|
130
|
+
GIT_COMMITTER_EMAIL="278929599+classytic-bot[bot]@users.noreply.github.com" \
|
|
131
|
+
git commit -m "feat(arc-x): …"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Push through `@classytic/dev-tools` so the commit and pusher both carry the `[bot]` badge (it
|
|
135
|
+
mints a short-lived GitHub App installation token, pushes, and never persists it):
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm run push -- main # push current work to main
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Publish to **public npm** (`publishConfig.access: "public"` is already set):
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm publish -w @classytic/arc-<domain> # prepublishOnly gate runs automatically
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Third-party (non-classytic) packages: normal git identity, same gates and same
|
|
148
|
+
public/scoped-npm publish — only the bot identity and `classytic-push` are classytic-specific.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Versioning a public export
|
|
153
|
+
|
|
154
|
+
SemVer the **contract**, not the internals:
|
|
155
|
+
|
|
156
|
+
| Change | Bump |
|
|
157
|
+
|---|---|
|
|
158
|
+
| New optional config field, new exported helper, new action | **minor** |
|
|
159
|
+
| Raising a peer floor (e.g. now requires arc ≥2.21) | **minor** (it narrows the host set) |
|
|
160
|
+
| Changing an exported function signature, removing an export, renaming an action/field | **major** |
|
|
161
|
+
| Internal refactor, dep bump within range, doc/test-only | **patch** |
|
|
162
|
+
|
|
163
|
+
The `ArcModule` shape you return, your exported function signatures, and your action/schema
|
|
164
|
+
surface are the contract consumers pin against. Internals (private helpers, how the engine is
|
|
165
|
+
built) are free to change under a patch.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Common publish failures (and the fix)
|
|
170
|
+
|
|
171
|
+
- **Peer written as `^` instead of `>=`** → blocks hosts on a newer major that still satisfies
|
|
172
|
+
your API. Fix: `">=X.Y.0"`.
|
|
173
|
+
- **arc/fastify in `dependencies`** → host gets a bundled duplicate; plugin registry splits.
|
|
174
|
+
Fix: move to `peerDependencies`.
|
|
175
|
+
- **Optional-peer types in `dist/*.d.mts`** → hosts without that peer fail to compile your
|
|
176
|
+
package. Fix: structural typing at the public boundary (see package-manifest.md).
|
|
177
|
+
- **Deep import (`@classytic/arc/src/...`)** → breaks on any arc internal reorg. Fix: use a
|
|
178
|
+
public subpath, or file the missing export against arc.
|
|
179
|
+
- **A back-edge: arc depending on your package** → un-publishable cycle. Fix: the abstraction
|
|
180
|
+
belongs in arc as a port/hook, not as a dependency on you.
|
|
181
|
+
- **Skewed devDep below peer floor** → suite tests a version you claim to support but don't.
|
|
182
|
+
Fix: raise the devDep, reinstall, re-run peer-skew.
|