@classytic/arc 2.8.4 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -5
- package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-Vu2yc56T.mjs} +188 -102
- package/dist/EventTransport-CqZ8FyM_.d.mts +293 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/audit/index.d.mts +100 -11
- package/dist/audit/index.mjs +71 -18
- package/dist/auth/index.d.mts +15 -7
- package/dist/auth/index.mjs +13 -6
- package/dist/{betterAuthOpenApi-C5lDyRH2.mjs → betterAuthOpenApi--rdY15Ld.mjs} +1 -1
- package/dist/cache/index.d.mts +71 -1
- package/dist/cache/index.mjs +96 -3
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -5
- package/dist/{core-DKSwNSXf.mjs → core-DNncu0xF.mjs} +1 -1
- package/dist/{createActionRouter-Df1BuawX.mjs → createActionRouter-DH1YFL9m.mjs} +3 -3
- package/dist/{createApp-BOYjBgdI.mjs → createApp-CBJUJKGP.mjs} +6 -5
- package/dist/{defineResource-Bb_Bdhtw.mjs → defineResource-C__jkwvs.mjs} +22 -57
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/dynamic/index.d.mts +2 -2
- package/dist/dynamic/index.mjs +3 -3
- package/dist/{elevation-BBGFjzIP.mjs → elevation-DxQ6ACbt.mjs} +20 -6
- package/dist/{errorHandler-mzqk4cGl.mjs → errorHandler-CZDW4EXS.mjs} +59 -7
- package/dist/{errorHandler-CdZDavNH.d.mts → errorHandler-DixGcttC.d.mts} +37 -2
- package/dist/{eventPlugin-CVxlE6De.d.mts → eventPlugin-BxvaCIZF.d.mts} +14 -2
- package/dist/{eventPlugin-D91S2YF4.mjs → eventPlugin-Dl7MoVWH.mjs} +83 -5
- package/dist/events/index.d.mts +147 -36
- package/dist/events/index.mjs +338 -101
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/{fields-DC4So2M2.d.mts → fields-BC7zcmI9.d.mts} +15 -3
- package/dist/{fields-ipsbIRPK.mjs → fields-CU6FlaDV.mjs} +18 -5
- package/dist/filesUpload-q8oHt--L.mjs +377 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +28 -4
- package/dist/idempotency/index.mjs +111 -2
- package/dist/idempotency/redis.d.mts +2 -2
- package/dist/idempotency/redis.mjs +134 -13
- package/dist/{index-CSkeivBx.d.mts → index-C-xjcA6F.d.mts} +2 -2
- package/dist/{index-CpTSDqmD.d.mts → index-Cibkchnx.d.mts} +5 -136
- package/dist/{index-BgmMdpm8.d.mts → index-CtGKT0lf.d.mts} +1 -1
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +8 -8
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/jobs.d.mts +25 -3
- package/dist/integrations/jobs.mjs +63 -4
- package/dist/integrations/mcp/index.d.mts +26 -8
- package/dist/integrations/mcp/index.mjs +96 -17
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +5 -0
- package/dist/integrations/webhooks.mjs +6 -0
- package/dist/{interface-BVuMfeVv.d.mts → interface-YrWsmKqE.d.mts} +324 -194
- package/dist/{openapi-CYCuekCn.mjs → openapi-CXuTG1M9.mjs} +3 -3
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-CH4cNwJi.mjs → permissions-oNZawnkR.mjs} +1 -1
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +4 -4
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/policies/index.d.mts +26 -33
- package/dist/presets/filesUpload.d.mts +71 -0
- package/dist/presets/filesUpload.mjs +2 -0
- package/dist/presets/index.d.mts +4 -2
- package/dist/presets/index.mjs +4 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +1 -1
- package/dist/presets/search.d.mts +91 -0
- package/dist/presets/search.mjs +150 -0
- package/dist/{presets-C2xgzW6x.mjs → presets-hM4WhNWY.mjs} +1 -1
- package/dist/{queryCachePlugin-D0iIVhW_.mjs → queryCachePlugin-DbUVroUG.mjs} +2 -2
- package/dist/redis-MXLp1oOf.d.mts +115 -0
- package/dist/{redis-stream-D54N5oXs.d.mts → redis-stream-Bz-4q96t.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/{resourceToTools-O_HwWXFa.mjs → resourceToTools-C3cWymnW.mjs} +65 -48
- package/dist/rpc/index.mjs +1 -1
- package/dist/{schemaConverter-OxfCshus.mjs → schemaConverter-BxFDdtXu.mjs} +25 -9
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +1 -1
- package/dist/storage-BwGQXUpd.d.mts +146 -0
- package/dist/store-helpers-DFiZl5TL.mjs +57 -0
- package/dist/testing/index.d.mts +7 -15
- package/dist/testing/index.mjs +23 -76
- package/dist/testing/storageContract.d.mts +26 -0
- package/dist/testing/storageContract.mjs +216 -0
- package/dist/types/index.d.mts +5 -5
- package/dist/types/storage.d.mts +2 -0
- package/dist/types/storage.mjs +1 -0
- package/dist/{types-CcG4avic.d.mts → types-CoSzA-s-.d.mts} +1 -1
- package/dist/{types-Bg2X42_m.d.mts → types-CunEX4UX.d.mts} +7 -5
- package/dist/{types-CVC4HOKi.d.mts → types-DZi1aYhm.d.mts} +1 -1
- package/dist/utils/index.d.mts +26 -8
- package/dist/utils/index.mjs +6 -6
- package/dist/{utils-yYT3HDXt.mjs → utils-B7FuRr9w.mjs} +1 -1
- package/package.json +23 -11
- package/skills/arc/SKILL.md +92 -14
- package/skills/arc/references/auth.md +94 -0
- package/skills/arc/references/events.md +229 -12
- package/skills/arc/references/mcp.md +4 -17
- package/skills/arc/references/multi-tenancy.md +43 -0
- package/skills/arc/references/production.md +34 -19
- package/dist/EventTransport-CinyO7zQ.d.mts +0 -135
- package/dist/audit/mongodb.d.mts +0 -2
- package/dist/audit/mongodb.mjs +0 -2
- package/dist/idempotency/mongodb.d.mts +0 -2
- package/dist/idempotency/mongodb.mjs +0 -123
- package/dist/mongodb-B5O6xaW1.mjs +0 -90
- package/dist/mongodb-B8U2xaLj.d.mts +0 -127
- package/dist/mongodb-X7LbEjTN.d.mts +0 -80
- package/dist/redis-z3sFr1UP.d.mts +0 -49
- /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-bqGpo9ML.mjs} +0 -0
- /package/dist/{circuitBreaker-cmi5XDv5.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
- /package/dist/{elevation-s5ykdNHr.d.mts → elevation-B6S5csVA.d.mts} +0 -0
- /package/dist/{errors-Bmn3eZT6.d.mts → errors-BI8kEKsO.d.mts} +0 -0
- /package/dist/{errors-BF2bIOIS.mjs → errors-CqWnSqM-.mjs} +0 -0
- /package/dist/{memory-Cp7_cAko.mjs → memory-BFAYkf8H.mjs} +0 -0
- /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-CWP6MB39.mjs} +0 -0
- /package/dist/{queryParser-CgCtsjti.mjs → queryParser-Cs-6SHQK.mjs} +0 -0
- /package/dist/{requestContext-DYvHl113.mjs → requestContext-DYtmNpm5.mjs} +0 -0
- /package/dist/{tracing-DxjKk7eW.d.mts → tracing-xqXzWeaf.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
- /package/dist/{types-C72d3NDn.d.mts → types-BD85MlEK.d.mts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
4
4
|
"description": "Resource-oriented backend framework for Fastify — clean, minimal, powerful, tree-shakable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"types": "./dist/types/index.d.mts",
|
|
21
21
|
"default": "./dist/types/index.mjs"
|
|
22
22
|
},
|
|
23
|
+
"./types/storage": {
|
|
24
|
+
"types": "./dist/types/storage.d.mts",
|
|
25
|
+
"default": "./dist/types/storage.mjs"
|
|
26
|
+
},
|
|
23
27
|
"./adapters": {
|
|
24
28
|
"types": "./dist/adapters/index.d.mts",
|
|
25
29
|
"default": "./dist/adapters/index.mjs"
|
|
@@ -36,6 +40,14 @@
|
|
|
36
40
|
"types": "./dist/presets/multiTenant.d.mts",
|
|
37
41
|
"default": "./dist/presets/multiTenant.mjs"
|
|
38
42
|
},
|
|
43
|
+
"./presets/files-upload": {
|
|
44
|
+
"types": "./dist/presets/filesUpload.d.mts",
|
|
45
|
+
"default": "./dist/presets/filesUpload.mjs"
|
|
46
|
+
},
|
|
47
|
+
"./presets/search": {
|
|
48
|
+
"types": "./dist/presets/search.d.mts",
|
|
49
|
+
"default": "./dist/presets/search.mjs"
|
|
50
|
+
},
|
|
39
51
|
"./auth": {
|
|
40
52
|
"types": "./dist/auth/index.d.mts",
|
|
41
53
|
"default": "./dist/auth/index.mjs"
|
|
@@ -76,10 +88,6 @@
|
|
|
76
88
|
"types": "./dist/audit/index.d.mts",
|
|
77
89
|
"default": "./dist/audit/index.mjs"
|
|
78
90
|
},
|
|
79
|
-
"./audit/mongodb": {
|
|
80
|
-
"types": "./dist/audit/mongodb.d.mts",
|
|
81
|
-
"default": "./dist/audit/mongodb.mjs"
|
|
82
|
-
},
|
|
83
91
|
"./docs": {
|
|
84
92
|
"types": "./dist/docs/index.d.mts",
|
|
85
93
|
"default": "./dist/docs/index.mjs"
|
|
@@ -92,10 +100,6 @@
|
|
|
92
100
|
"types": "./dist/idempotency/redis.d.mts",
|
|
93
101
|
"default": "./dist/idempotency/redis.mjs"
|
|
94
102
|
},
|
|
95
|
-
"./idempotency/mongodb": {
|
|
96
|
-
"types": "./dist/idempotency/mongodb.d.mts",
|
|
97
|
-
"default": "./dist/idempotency/mongodb.mjs"
|
|
98
|
-
},
|
|
99
103
|
"./events": {
|
|
100
104
|
"types": "./dist/events/index.d.mts",
|
|
101
105
|
"default": "./dist/events/index.mjs"
|
|
@@ -116,6 +120,10 @@
|
|
|
116
120
|
"types": "./dist/testing/index.d.mts",
|
|
117
121
|
"default": "./dist/testing/index.mjs"
|
|
118
122
|
},
|
|
123
|
+
"./testing/storage": {
|
|
124
|
+
"types": "./dist/testing/storageContract.d.mts",
|
|
125
|
+
"default": "./dist/testing/storageContract.mjs"
|
|
126
|
+
},
|
|
119
127
|
"./policies": {
|
|
120
128
|
"types": "./dist/policies/index.d.mts",
|
|
121
129
|
"default": "./dist/policies/index.mjs"
|
|
@@ -227,7 +235,7 @@
|
|
|
227
235
|
"node": ">=22"
|
|
228
236
|
},
|
|
229
237
|
"peerDependencies": {
|
|
230
|
-
"@classytic/mongokit": ">=3.
|
|
238
|
+
"@classytic/mongokit": ">=3.8.0",
|
|
231
239
|
"@classytic/streamline": ">=2.1.0",
|
|
232
240
|
"@fastify/cors": ">=11.0.0",
|
|
233
241
|
"@fastify/helmet": ">=13.0.0",
|
|
@@ -346,7 +354,7 @@
|
|
|
346
354
|
"devDependencies": {
|
|
347
355
|
"@better-auth/mongo-adapter": "^1.6.2",
|
|
348
356
|
"@biomejs/biome": "^2.4.11",
|
|
349
|
-
"@classytic/mongokit": "
|
|
357
|
+
"@classytic/mongokit": "^3.8.0",
|
|
350
358
|
"@classytic/streamline": "^2.1.0",
|
|
351
359
|
"@fastify/cors": "^11.2.0",
|
|
352
360
|
"@fastify/helmet": "^13.0.2",
|
|
@@ -364,7 +372,11 @@
|
|
|
364
372
|
"@vitest/coverage-v8": "^3.2.4",
|
|
365
373
|
"ajv": "^8.18.0",
|
|
366
374
|
"better-auth": "^1.6.2",
|
|
375
|
+
"bullmq": "^5.73.5",
|
|
376
|
+
"dotenv": "^17.4.2",
|
|
377
|
+
"fast-check": "^4.6.0",
|
|
367
378
|
"fastify-raw-body": "^5.0.0",
|
|
379
|
+
"ioredis": "^5.10.1",
|
|
368
380
|
"jsonwebtoken": "^9.0.0",
|
|
369
381
|
"knip": "^6.4.1",
|
|
370
382
|
"mongodb": "^7.1.0",
|
package/skills/arc/SKILL.md
CHANGED
|
@@ -8,11 +8,11 @@ description: |
|
|
|
8
8
|
Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
|
|
9
9
|
arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
|
|
10
10
|
arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
|
|
11
|
-
version: 2.
|
|
11
|
+
version: 2.9.1
|
|
12
12
|
license: MIT
|
|
13
13
|
metadata:
|
|
14
14
|
author: Classytic
|
|
15
|
-
version: "2.
|
|
15
|
+
version: "2.9.1"
|
|
16
16
|
tags:
|
|
17
17
|
- fastify
|
|
18
18
|
- rest-api
|
|
@@ -68,6 +68,22 @@ await app.register(productResource.toPlugin());
|
|
|
68
68
|
await app.listen({ port: 8040, host: '0.0.0.0' });
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
## v2.9 Security Defaults (breaking)
|
|
72
|
+
|
|
73
|
+
- **Field-write perms: `reject` (default)** — requests carrying non-writable fields get 403 with denied-field list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
|
|
74
|
+
- **multiTenant injects org on UPDATE** — body-supplied `organizationId` overwritten with caller's scope. Closes tenant-hop vector.
|
|
75
|
+
- **Elevation always emits `arc.scope.elevated` event** — audit via `fastify.events.subscribe(...)`.
|
|
76
|
+
- **`verifySignature(body, ...)` throws on parsed body** — pass `req.rawBody`, not `req.body`.
|
|
77
|
+
- **Upload `sanitizeFilename`** — strict by default (no `/ \ .. \0 >255ch`). Pass `false` / `'*'` / custom fn to relax.
|
|
78
|
+
- **Idempotency `namespace`** option for shared-store prod+canary deployments.
|
|
79
|
+
|
|
80
|
+
## Removed in v2.9
|
|
81
|
+
|
|
82
|
+
- `createActionRouter`, `buildActionBodySchema` — use `actions` on `defineResource`
|
|
83
|
+
- `ResourceConfig.onRegister` — use `actions` or resource `hooks`
|
|
84
|
+
- `AdditionalRoute` type + `resource.additionalRoutes` field — use `RouteDefinition` and `resource.routes` (single source of truth; no normalised mirror)
|
|
85
|
+
- `wrapHandler` on route defs — derived from `!route.raw` at use-site (set `raw: true` to opt out of the arc pipeline)
|
|
86
|
+
|
|
71
87
|
## defineResource()
|
|
72
88
|
|
|
73
89
|
Single API to define a full REST resource:
|
|
@@ -109,7 +125,7 @@ const productResource = defineResource({
|
|
|
109
125
|
{ method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: auth() },
|
|
110
126
|
],
|
|
111
127
|
|
|
112
|
-
// Actions
|
|
128
|
+
// Actions — single POST /:id/action endpoint, discriminated on `action` body field
|
|
113
129
|
actions: {
|
|
114
130
|
approve: async (id, data, req) => service.approve(id, req.user._id),
|
|
115
131
|
cancel: {
|
|
@@ -489,6 +505,8 @@ permissions: { list: acl.canAction('product', 'read') }
|
|
|
489
505
|
| `multiTenant` | none (middleware) | — | `{ tenantField }` OR `{ tenantFields: TenantFieldSpec[] }` (2.7.1+) |
|
|
490
506
|
| `audited` | none (middleware) | — | — |
|
|
491
507
|
| `bulk` | POST/PATCH/DELETE /bulk | — | `{ operations?, maxCreateItems? }` |
|
|
508
|
+
| `filesUpload` | POST /upload, GET /:id, DELETE /:id | — (uses `Storage` adapter) | `{ storage, sanitizeFilename?, allowedMimeTypes?, maxFileSize? }` |
|
|
509
|
+
| `search` | POST /search, /search-similar, /embed (opt-in) | — | `{ repository?, search?, similar?, embed?, routes? }` |
|
|
492
510
|
|
|
493
511
|
```typescript
|
|
494
512
|
// Single-field (default, backwards compatible)
|
|
@@ -546,7 +564,7 @@ Default is `'_id'`. Override for resources keyed by a business identifier (UUID,
|
|
|
546
564
|
defineResource({
|
|
547
565
|
name: 'job',
|
|
548
566
|
adapter: createMongooseAdapter(JobModel, jobRepository),
|
|
549
|
-
idField: 'jobId', // ← one line
|
|
567
|
+
idField: 'jobId', // ← one line (or omit and auto-derive from repository.idField — see below)
|
|
550
568
|
});
|
|
551
569
|
|
|
552
570
|
// GET /jobs/job-5219f346-a4d → controller runs { jobId: 'job-5219f346-a4d' }
|
|
@@ -559,10 +577,66 @@ Changes all three layers:
|
|
|
559
577
|
- **OpenAPI docs** — `spec.paths['/jobs/{id}']` emits a plain string `id` with description
|
|
560
578
|
- **MCP tools** — auto-generated CRUD tools use `idField` transparently
|
|
561
579
|
|
|
562
|
-
|
|
580
|
+
**Auto-derive from repository** (2.7.x+). If you don't set `idField` on `defineResource` but your `adapter.repository` exposes one (e.g. `new Repository(Model, [], {}, { idField: 'slug' })`), Arc picks it up automatically. Configure in one place, not two.
|
|
581
|
+
|
|
582
|
+
**URL path segment is ALWAYS `:id` and `req.params.id` is ALWAYS named `id`** — regardless of what `idField` is set to. `idField` controls the *lookup field*, not the *URL parameter name*. This is the same convention Stripe / GitHub / most REST APIs use:
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
// idField: 'slug'
|
|
586
|
+
// Client sends: POST /agents/sadman/action
|
|
587
|
+
// In handler: req.params.id === 'sadman' ← always keyed 'id'
|
|
588
|
+
// repo.update(id, data) ← mongokit resolves by slug via repo.idField
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
This applies to CRUD routes (`GET/PATCH/DELETE /:id`), action routes (`POST /:id/action`), and any `routes: [...]` entries that use `:id`.
|
|
592
|
+
|
|
593
|
+
**Common confusion pattern.** A 404 on `PATCH /agents/sadman` when `GET /agents/sadman` returns 200 looks like an `idField` bug but usually isn't. Check whether your **update permission** returns `filters` — arc merges those into the compound DB lookup (`{ slug: 'sadman', ...filters }`), and a filter that excludes the doc is what's returning null. Fix is in the permission, not `idField`.
|
|
594
|
+
|
|
595
|
+
User-provided `openApiSchemas.params` still overrides everything.
|
|
563
596
|
|
|
564
597
|
For custom adapters, honor the new `AdapterSchemaContext` passed to `generateSchemas(options, context?)` to emit the right `params.id` pattern from the start. Legacy adapters still work — Arc's safety net strips mismatched ObjectId patterns automatically.
|
|
565
598
|
|
|
599
|
+
## searchPreset (text + vector + embed)
|
|
600
|
+
|
|
601
|
+
Backend-agnostic routes for Elasticsearch / OpenSearch / Algolia / Typesense / Atlas `$vectorSearch` / Pinecone / Qdrant / Milvus. Opt-in per section; `mcp: false` skips per path.
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
import { searchPreset } from '@classytic/arc/presets/search';
|
|
605
|
+
|
|
606
|
+
// A — auto-wire from a repo with search/searchSimilar/embed methods
|
|
607
|
+
// (mongokit's elasticSearchPlugin + vectorPlugin register exactly these).
|
|
608
|
+
// Each method's native calling convention is honoured:
|
|
609
|
+
// search(query, options) — positional (elasticSearchPlugin)
|
|
610
|
+
// searchSimilar(VectorSearchParams) — single object (vectorPlugin)
|
|
611
|
+
// embed(input) — single arg (vectorPlugin)
|
|
612
|
+
searchPreset({
|
|
613
|
+
repository: productRepo,
|
|
614
|
+
search: true, // POST /search → repo.search(body.query, body)
|
|
615
|
+
similar: true, // POST /search-similar → repo.searchSimilar(body)
|
|
616
|
+
// embed omitted → /embed not mounted
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// B — external backends + custom path + Zod schema
|
|
620
|
+
searchPreset({
|
|
621
|
+
search: {
|
|
622
|
+
path: '/full-text',
|
|
623
|
+
schema: { body: z.object({ q: z.string().min(1) }) },
|
|
624
|
+
handler: (req) => elastic.search({ index: 'products', q: req.body.q }),
|
|
625
|
+
},
|
|
626
|
+
similar: { handler: (req) => pinecone.query({ vector: req.body.vector, topK: 10 }), mcp: false },
|
|
627
|
+
routes: [ // bespoke paths
|
|
628
|
+
{ method: 'GET', path: '/autocomplete', permissions: allowPublic(),
|
|
629
|
+
handler: (req) => algolia.suggest((req.query as { q: string }).q) },
|
|
630
|
+
],
|
|
631
|
+
})
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
**Defaults:** search/similar → POST, permissions fall back to resource `list` → `allowPublic()`. Embed → POST + `requireAuth()`. Every field (`path`, `method`, `schema`, `permissions`, `mcp`, `summary`, `tags`, `operation`) is overridable per section. Zod v4 schemas auto-convert to JSON Schema for both Fastify validation and OpenAPI.
|
|
635
|
+
|
|
636
|
+
**MCP namespacing:** tool names are `{op}_{resource}` — many resources can register their own searchPreset under one `mcpPlugin` endpoint without colliding (`product_search`, `order_search`, …).
|
|
637
|
+
|
|
638
|
+
**When to use `routes` directly instead:** one-off search endpoints, or when you want full control without the preset's defaults.
|
|
639
|
+
|
|
566
640
|
## QueryCache
|
|
567
641
|
|
|
568
642
|
TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
|
|
@@ -652,7 +726,11 @@ CRUD events auto-emit: `{resource}.created`, `{resource}.updated`, `{resource}.d
|
|
|
652
726
|
|
|
653
727
|
**Transports:** Memory (default) | Redis Pub/Sub (fire-and-forget) | Redis Streams (durable, at-least-once, consumer groups, DLQ)
|
|
654
728
|
|
|
655
|
-
**Event Outbox** — at-least-once delivery via transactional outbox pattern.
|
|
729
|
+
**Event Outbox** — at-least-once delivery via transactional outbox pattern. Pass `repository: RepositoryLike` (mongokit / prismakit / custom) for production, or `store: MemoryOutboxStore()` for dev. Arc adapts the repo to the `OutboxStore` contract internally — `create` / `findAll` / `deleteMany` / `findOneAndUpdate` cover save, claim, ack, fail, DLQ.
|
|
730
|
+
|
|
731
|
+
**Event contract (v2.9):** `EventMeta` = `id`, `timestamp`, optional `schemaVersion`, `correlationId`, `causationId`, `partitionKey`, `source`, `idempotencyKey`, `resource`, `resourceId`, `userId`, `organizationId`, `aggregate: { type, id }`. `createChildEvent(parent, ...)` inherits correlation/causation/source/idempotencyKey; aggregate stays explicit. `DeadLetteredEvent<T>` + optional `transport.deadLetter()` for typed DLQ. `withRetry({ transport })` auto-routes exhausted events — no custom plumbing for Kafka/SQS. `@classytic/primitives` mirrors these shapes — arc is source of truth.
|
|
732
|
+
|
|
733
|
+
**Outbox (v2.9):** `EventOutbox.store()` auto-maps `meta.idempotencyKey` → `dedupeKey`. `new EventOutbox({ failurePolicy: ({ attempts }) => ({ retryAt, deadLetter }) })` centralises retry/DLQ. `outbox.getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`. `RelayResult.deadLettered` for per-batch DLQ count. Durable store: `new EventOutbox({ repository: new Repository(OutboxModel), transport })` (v2.9.1) — multi-worker claim, session-threaded writes, and dedupe semantics come from the repo's backing kit.
|
|
656
734
|
|
|
657
735
|
## Factory — createApp()
|
|
658
736
|
|
|
@@ -948,7 +1026,7 @@ defineResource({ name: 'order', audit: true });
|
|
|
948
1026
|
defineResource({ name: 'payment', audit: { operations: ['delete'] } });
|
|
949
1027
|
defineResource({ name: 'product' }); // not audited
|
|
950
1028
|
|
|
951
|
-
// Manual custom() for MCP/
|
|
1029
|
+
// Manual custom() for MCP tools / custom routes / read auditing
|
|
952
1030
|
app.post('/orders/:id/refund', async (req) => {
|
|
953
1031
|
await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
|
|
954
1032
|
});
|
|
@@ -980,7 +1058,6 @@ permissions: {
|
|
|
980
1058
|
|
|
981
1059
|
```typescript
|
|
982
1060
|
// Typed request for raw routes — no more (req as any).user
|
|
983
|
-
// v2.8: `raw: true` replaces `wrapHandler: false` (wrapHandler still works but is deprecated)
|
|
984
1061
|
import type { ArcRequest } from '@classytic/arc';
|
|
985
1062
|
handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
|
|
986
1063
|
|
|
@@ -996,15 +1073,16 @@ const { userId, organizationId, roles, orgRoles } = getOrgContext(request);
|
|
|
996
1073
|
import { createDomainError } from '@classytic/arc';
|
|
997
1074
|
throw createDomainError('SELF_REFERRAL', 'Cannot refer yourself', 422);
|
|
998
1075
|
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1076
|
+
// Custom routes — always `routes: [...]` with optional `raw: true` for non-JSON
|
|
1077
|
+
defineResource({
|
|
1078
|
+
routes: [{ method: 'GET', path: '/stats', handler: 'getStats', permissions: allowPublic() }],
|
|
1079
|
+
});
|
|
1002
1080
|
|
|
1003
1081
|
// SSE auth — preAuth runs BEFORE auth middleware (EventSource can't set headers)
|
|
1004
|
-
|
|
1082
|
+
routes: [{ preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] }]
|
|
1005
1083
|
|
|
1006
|
-
// SSE streaming —
|
|
1007
|
-
|
|
1084
|
+
// SSE streaming — raw: true + stream the response
|
|
1085
|
+
routes: [{ method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) }]
|
|
1008
1086
|
```
|
|
1009
1087
|
|
|
1010
1088
|
## DX Helpers (v2.7.3+)
|
|
@@ -225,6 +225,100 @@ auth: {
|
|
|
225
225
|
}
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
## Service Identity (Machine Principals)
|
|
229
|
+
|
|
230
|
+
Arc distinguishes **machine callers** (API clients, spawned agents, cron workers) from **human users** via `RequestScope.service` — a first-class scope kind with `clientId` + OAuth-style `scopes`. Any auth mechanism (arc JWT, API keys, Better Auth client credentials, mTLS, gateway headers) can install it; arc does not ship a "service token" issuer — the app picks its credential flow and maps the result to scope.
|
|
231
|
+
|
|
232
|
+
Install service scope from your `authenticate` callback — arc's default JWT verify installs `member` scope by design, so custom authenticators are the extension point:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
await createApp({
|
|
236
|
+
auth: {
|
|
237
|
+
type: 'jwt',
|
|
238
|
+
jwt: { secret: process.env.JWT_SECRET! },
|
|
239
|
+
authenticate: async (request, { jwt }) => {
|
|
240
|
+
const token = request.headers.authorization?.slice(7);
|
|
241
|
+
if (!token) return null;
|
|
242
|
+
const decoded = jwt.verify(token) as {
|
|
243
|
+
clientId?: string; userId?: string;
|
|
244
|
+
organizationId?: string; scopes?: string[];
|
|
245
|
+
};
|
|
246
|
+
// Machine principal — clientId present, no human user record.
|
|
247
|
+
if (decoded.clientId) {
|
|
248
|
+
request.scope = {
|
|
249
|
+
kind: 'service',
|
|
250
|
+
clientId: decoded.clientId,
|
|
251
|
+
organizationId: decoded.organizationId ?? '',
|
|
252
|
+
scopes: decoded.scopes ?? [],
|
|
253
|
+
};
|
|
254
|
+
return { clientId: decoded.clientId }; // returned value → request.user
|
|
255
|
+
}
|
|
256
|
+
// Human principal — fall through to user lookup.
|
|
257
|
+
return await User.findById(decoded.userId);
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Gate routes on service identity using the scope helpers + a permission builder:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
import { isService, getClientId, getServiceScopes } from '@classytic/arc/scope';
|
|
267
|
+
import type { PermissionCheck } from '@classytic/arc/permissions';
|
|
268
|
+
|
|
269
|
+
const requireScope = (scope: string): PermissionCheck => (ctx) => {
|
|
270
|
+
if (!isService(ctx.request.scope)) return { granted: false, reason: 'service only' };
|
|
271
|
+
const has = getServiceScopes(ctx.request.scope).includes(scope);
|
|
272
|
+
return has ? { granted: true } : { granted: false, reason: `missing scope: ${scope}` };
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
defineResource({
|
|
276
|
+
name: 'order',
|
|
277
|
+
permissions: { create: requireScope('orders:write') },
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**Mixing humans and services on the same route** — compose arc's builders:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// Humans with 'editor' role OR services with 'orders:write' scope
|
|
285
|
+
permissions: {
|
|
286
|
+
create: anyOf(requireOrgRole('editor'), requireScope('orders:write')),
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Audit differentiation** — because `scope.kind === 'service'` carries `clientId` instead of `userId`, your audit plugin's `actor` resolver can tag machine actions distinctly:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
auditPlugin({
|
|
294
|
+
actor: (request) =>
|
|
295
|
+
isService(request.scope)
|
|
296
|
+
? { kind: 'service', clientId: getClientId(request.scope) }
|
|
297
|
+
: { kind: 'user', userId: getUserId(request.scope) },
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Rate-limit separation** — gate a separate bucket per `clientId` so one noisy agent can't starve humans in the same org:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
rateLimit: {
|
|
305
|
+
keyGenerator: (req) =>
|
|
306
|
+
isService(req.scope) ? `svc:${getClientId(req.scope)}` : `org:${getOrgId(req.scope)}`,
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Credential-flow choice is the app's** — arc does not pick for you:
|
|
311
|
+
|
|
312
|
+
| Flow | Wire via `authenticate` callback |
|
|
313
|
+
|---|---|
|
|
314
|
+
| Arc JWT with `clientId` claim | example above |
|
|
315
|
+
| API key in header (DB lookup) | read `x-api-key`, look up client, set `scope` to service |
|
|
316
|
+
| Better Auth client credentials | `auth.api.getSession({ headers })`, detect client-credential grant |
|
|
317
|
+
| mTLS cert DN → client | read cert from terminator header, map DN → `clientId` |
|
|
318
|
+
| OAuth2 client-credentials | verify token via introspection endpoint |
|
|
319
|
+
|
|
320
|
+
All five produce the same downstream primitive: `RequestScope.service`. Permission builders, audit hooks, and rate-limit keys compose the same way regardless.
|
|
321
|
+
|
|
228
322
|
## Microservice Gateway Pattern
|
|
229
323
|
|
|
230
324
|
```
|
|
@@ -116,22 +116,73 @@ const unsub = await fastify.events.subscribe('order.created', handler);
|
|
|
116
116
|
unsub();
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
## Event Structure
|
|
119
|
+
## Event Structure (v2.9)
|
|
120
120
|
|
|
121
121
|
```typescript
|
|
122
|
+
interface EventMeta {
|
|
123
|
+
id: string; // UUID v4 (fresh per emit; a retry gets a new id)
|
|
124
|
+
timestamp: Date;
|
|
125
|
+
schemaVersion?: number; // bump on payload breaking change
|
|
126
|
+
correlationId?: string; // stable across causal chain
|
|
127
|
+
causationId?: string; // direct parent event id
|
|
128
|
+
partitionKey?: string; // ordering hint (Kafka/Kinesis/Streams)
|
|
129
|
+
source?: string; // originating service/package ('commerce', 'billing')
|
|
130
|
+
idempotencyKey?: string; // cross-transport dedupe hint — stable per operation
|
|
131
|
+
resource?: string;
|
|
132
|
+
resourceId?: string;
|
|
133
|
+
userId?: string;
|
|
134
|
+
organizationId?: string;
|
|
135
|
+
aggregate?: { type: string; id: string }; // DDD aggregate marker
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
interface DomainEvent<T> {
|
|
123
|
-
type: string;
|
|
139
|
+
type: string; // e.g., 'order.created'
|
|
124
140
|
payload: T;
|
|
125
|
-
meta:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
meta: EventMeta;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Arc is source of truth** — `@classytic/primitives/events` mirrors these shapes. Downstream packages import from primitives; arc owns evolution.
|
|
146
|
+
|
|
147
|
+
### DDD aggregate narrowing
|
|
148
|
+
|
|
149
|
+
`aggregate.type` is `string` in arc's base contract so it stays framework-neutral. Domain packages narrow it to a closed union via interface extension:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// @classytic/cart
|
|
153
|
+
type CartAggregateType = 'cart' | 'cart-item';
|
|
154
|
+
|
|
155
|
+
interface CartEventMeta extends EventMeta {
|
|
156
|
+
aggregate?: { type: CartAggregateType; id: string };
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Unlike `correlationId` / `causationId`, `aggregate` is **not inherited** by `createChildEvent`. Child events usually belong to a different aggregate (e.g. an `order.placed` event emitted by the order aggregate spawns `inventory.reserved` owned by the inventory aggregate). Each event names its own aggregate explicitly.
|
|
161
|
+
|
|
162
|
+
### Causation chains
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { createEvent, createChildEvent } from '@classytic/arc/events';
|
|
166
|
+
|
|
167
|
+
const placed = createEvent('order.placed', { orderId: 'o1' }, {
|
|
168
|
+
correlationId: req.id, userId: user.id,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Downstream handler emits child — causation linked, correlation inherited:
|
|
172
|
+
const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
|
|
173
|
+
// reserved.meta.causationId === placed.meta.id
|
|
174
|
+
// reserved.meta.correlationId === placed.meta.correlationId
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Dead-letter contract
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import type { DeadLetteredEvent, EventTransport } from '@classytic/arc/events';
|
|
181
|
+
|
|
182
|
+
class KafkaTransport implements EventTransport {
|
|
183
|
+
async deadLetter(dlq: DeadLetteredEvent) {
|
|
184
|
+
await producer.send({ topic: `${dlq.event.type}.DLQ`, messages: [{ value: JSON.stringify(dlq) }] });
|
|
185
|
+
}
|
|
135
186
|
}
|
|
136
187
|
```
|
|
137
188
|
|
|
@@ -168,6 +219,35 @@ class KafkaTransport implements EventTransport {
|
|
|
168
219
|
| Redis Pub/Sub | `@classytic/arc/events/redis` | Multi-instance, real-time |
|
|
169
220
|
| Redis Streams | `@classytic/arc/events/redis-stream` | Ordered, persistent, consumer groups |
|
|
170
221
|
|
|
222
|
+
### Streams vs Pub/Sub — pick the right one
|
|
223
|
+
|
|
224
|
+
Choosing wrong loses messages silently. Default to **Streams** for anything business-critical.
|
|
225
|
+
|
|
226
|
+
| Requirement | Use |
|
|
227
|
+
|---|---|
|
|
228
|
+
| Message MUST NOT be lost (billing, payments, audit) | **Streams** |
|
|
229
|
+
| Real-time notifications, OK to miss when no subscriber is up | Pub/Sub |
|
|
230
|
+
| Need to replay/reprocess past events | **Streams** |
|
|
231
|
+
| Multiple workers processing the same queue | **Streams** (consumer groups) |
|
|
232
|
+
| Simple broadcast to live WebSocket clients | Pub/Sub |
|
|
233
|
+
| Event sourcing or audit trail | **Streams** |
|
|
234
|
+
| Single-instance dev | Memory |
|
|
235
|
+
| At-least-once delivery with durable WAL | **Streams** + outbox pattern |
|
|
236
|
+
|
|
237
|
+
**Why it matters:** Pub/Sub is fire-and-forget. If no subscriber is connected when you publish, the message is gone. Streams persist until every consumer group acknowledges them — crashes, restarts, and network blips are survivable.
|
|
238
|
+
|
|
239
|
+
**Defense-in-depth:** pair `eventPlugin` with the transactional outbox (`EventOutbox` + `MemoryOutboxStore` or your own persistent store) for guaranteed delivery even if Redis is unreachable at publish time.
|
|
240
|
+
|
|
241
|
+
### Redis eviction policy — required for queues and idempotency
|
|
242
|
+
|
|
243
|
+
When you back events (Streams), jobs (BullMQ), idempotency, or cache with Redis, your Redis instance **must** be configured with `maxmemory-policy: noeviction`. Any other policy can silently evict in-flight stream entries or pending jobs.
|
|
244
|
+
|
|
245
|
+
- **Self-hosted Redis:** `redis-cli CONFIG SET maxmemory-policy noeviction` (or set in `redis.conf`).
|
|
246
|
+
- **Upstash:** free/paid DBs default to `optimistic-volatile`. You'll see `IMPORTANT! Eviction policy is optimistic-volatile. It should be "noeviction"` in BullMQ logs. **Do one of:** open a support ticket to request `noeviction`, use a dedicated DB for queues, or accept that long-idle jobs may be evicted.
|
|
247
|
+
- **ElastiCache / Redis Cloud:** set the parameter group's `maxmemory-policy` to `noeviction` before pointing arc at it.
|
|
248
|
+
|
|
249
|
+
For a pure cache DB (no queues, no idempotency), `allkeys-lru` is correct and what you want.
|
|
250
|
+
|
|
171
251
|
## Injectable Logger
|
|
172
252
|
|
|
173
253
|
All transports and retry accept a `logger` option — defaults to `console`, compatible with pino/fastify.log:
|
|
@@ -270,3 +350,140 @@ const retriedHandler = withRetry(async (event) => {
|
|
|
270
350
|
|
|
271
351
|
await fastify.events.subscribe('order.created', retriedHandler);
|
|
272
352
|
```
|
|
353
|
+
|
|
354
|
+
### Auto-route exhausted events to transport.deadLetter() (v2.9)
|
|
355
|
+
|
|
356
|
+
For transports with a native DLQ (Kafka DLQ topic, SQS DLQ queue, etc.), pass
|
|
357
|
+
`transport` to skip custom `$deadLetter` plumbing:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
await fastify.events.subscribe(
|
|
361
|
+
'order.created',
|
|
362
|
+
withRetry(handler, {
|
|
363
|
+
maxRetries: 3,
|
|
364
|
+
transport: fastify.events.transport, // any EventTransport with deadLetter()
|
|
365
|
+
name: 'emailProcessor', // populates DeadLetteredEvent.handlerName
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
On exhaustion, a typed `DeadLetteredEvent` envelope (original event + error +
|
|
371
|
+
attempts + first/last failure timestamps) is handed to `transport.deadLetter()`.
|
|
372
|
+
`onDead` still works — both fire when both are configured.
|
|
373
|
+
|
|
374
|
+
## Transactional Outbox (v2.9)
|
|
375
|
+
|
|
376
|
+
**Why the outbox exists:** you write a row + publish an event in the same user
|
|
377
|
+
request. If the transport (Redis/Kafka) is down at that moment, the row
|
|
378
|
+
commits but the event vanishes — silent data divergence. The outbox persists
|
|
379
|
+
the event in the **same DB transaction** as the row, then a background relayer
|
|
380
|
+
guarantees at-least-once delivery to the transport. Multi-worker claim +
|
|
381
|
+
retry/DLQ policy + dedupe make it scale.
|
|
382
|
+
|
|
383
|
+
`EventOutbox` now offers centralised retry/DLQ and typed DLQ query:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import { EventOutbox, MemoryOutboxStore, exponentialBackoff } from '@classytic/arc/events';
|
|
387
|
+
|
|
388
|
+
const outbox = new EventOutbox({
|
|
389
|
+
store: new MemoryOutboxStore(), // swap for durable store in prod
|
|
390
|
+
transport: fastify.events.transport,
|
|
391
|
+
|
|
392
|
+
// Centralised retry/DLQ — no more hand-rolled exponentialBackoff at every fail site
|
|
393
|
+
failurePolicy: ({ attempts, error }) => {
|
|
394
|
+
if (attempts >= 5) return { deadLetter: true };
|
|
395
|
+
return { retryAt: exponentialBackoff({ attempt: attempts }) };
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// meta.idempotencyKey auto-maps to OutboxWriteOptions.dedupeKey — duplicate
|
|
400
|
+
// saves with the same key are silently absorbed.
|
|
401
|
+
await outbox.store(
|
|
402
|
+
createEvent('order.placed', payload, { idempotencyKey: `order:${id}:placed` }),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Rich per-batch outcome — deadLettered is new in v2.9
|
|
406
|
+
const result = await outbox.relayBatch();
|
|
407
|
+
// { relayed, attempted, publishFailed, ackFailed, ownershipMismatches,
|
|
408
|
+
// malformed, failHookErrors, deadLettered, usedPublishMany }
|
|
409
|
+
|
|
410
|
+
// Read DLQ state as typed DeadLetteredEvent[]
|
|
411
|
+
const dlq = await outbox.getDeadLettered(100);
|
|
412
|
+
for (const envelope of dlq) {
|
|
413
|
+
await alertOps(envelope); // event, error, attempts, firstFailedAt, lastFailedAt
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Store capability tiers:**
|
|
418
|
+
|
|
419
|
+
| Method | Required | What you lose without it |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `save`, `getPending`, `acknowledge` | ✅ | — |
|
|
422
|
+
| `claimPending` | — | Multi-worker relay safety |
|
|
423
|
+
| `fail` | — | Retry / DLQ / per-event failure reporting |
|
|
424
|
+
| `getDeadLettered` | — | `outbox.getDeadLettered()` returns `[]` |
|
|
425
|
+
| `purge` | — | App owns retention (TTL index, cron DELETE, etc.) |
|
|
426
|
+
|
|
427
|
+
`MemoryOutboxStore` implements all capabilities — use it as a reference when
|
|
428
|
+
writing a durable store for Postgres / DynamoDB / your DB of choice.
|
|
429
|
+
|
|
430
|
+
### Durable store — pass a `RepositoryLike`
|
|
431
|
+
|
|
432
|
+
Arc adapts any `Repository` (mongokit / prismakit / your own kit) to the
|
|
433
|
+
`OutboxStore` contract — no dedicated subpath, no store class to
|
|
434
|
+
instantiate:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import mongoose from 'mongoose';
|
|
438
|
+
import { Repository } from '@classytic/mongokit';
|
|
439
|
+
import { EventOutbox, exponentialBackoff, createEvent } from '@classytic/arc/events';
|
|
440
|
+
|
|
441
|
+
const OutboxModel = mongoose.model('ArcOutbox', OutboxSchema, 'arc_outbox_events');
|
|
442
|
+
|
|
443
|
+
const outbox = new EventOutbox({
|
|
444
|
+
repository: new Repository(OutboxModel),
|
|
445
|
+
transport: redisTransport,
|
|
446
|
+
failurePolicy: ({ attempts }) =>
|
|
447
|
+
attempts >= 5 ? { deadLetter: true } : { retryAt: exponentialBackoff({ attempt: attempts }) },
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Persist event in the same DB transaction as the row
|
|
451
|
+
await mongoose.connection.transaction(async (session) => {
|
|
452
|
+
await Order.create([orderDoc], { session });
|
|
453
|
+
await outbox.store(
|
|
454
|
+
createEvent('order.placed', { orderId }, { idempotencyKey: `order:${orderId}:placed` }),
|
|
455
|
+
{ session },
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Background relayer
|
|
460
|
+
setInterval(async () => {
|
|
461
|
+
const r = await outbox.relayBatch();
|
|
462
|
+
metrics.gauge('outbox.deadLettered', r.deadLettered);
|
|
463
|
+
}, 1000);
|
|
464
|
+
|
|
465
|
+
// Ops: read dead-letter envelopes for alerting / replay
|
|
466
|
+
const dlq = await outbox.getDeadLettered(100);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**What you get:**
|
|
470
|
+
|
|
471
|
+
- **Atomic multi-worker claim** — arc's adapter uses `findOneAndUpdate`
|
|
472
|
+
on `{ status: 'pending', visibleAt ≤ now, lease free }`. Two racing
|
|
473
|
+
relayers never see the same event; expired leases auto-recover.
|
|
474
|
+
- **Session threading** — `outbox.store(event, { session })` flows
|
|
475
|
+
through `Repository.create(doc, { session })` so the event commits
|
|
476
|
+
with your business write.
|
|
477
|
+
- **Dedupe** — `meta.idempotencyKey` maps to `dedupeKey`; your kit's
|
|
478
|
+
unique index (or equivalent) enforces idempotency.
|
|
479
|
+
- **DLQ** — `getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`.
|
|
480
|
+
`RelayResult.deadLettered` counts per-batch transitions.
|
|
481
|
+
- **Purge** — `outbox.purge(olderThanMs)` deletes delivered rows; define
|
|
482
|
+
retention via a TTL index (`deliveredAt`), a cron, or a scheduler —
|
|
483
|
+
your kit's choice.
|
|
484
|
+
|
|
485
|
+
You own the schema and indexes. Recommended shape:
|
|
486
|
+
`{ eventId (unique), type, payload, meta, status, attempts, leaseOwner,
|
|
487
|
+
leaseExpiresAt, visibleAt, dedupeKey (unique sparse), lastError,
|
|
488
|
+
createdAt, deliveredAt }` with indexes on `{ status, visibleAt }` and
|
|
489
|
+
`{ deliveredAt }` (TTL).
|