@classytic/arc 2.32.0 → 2.34.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/README.md +1 -1
- package/bin/arc.js +58 -17
- package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CbkuhNI2.mjs} +329 -35
- package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
- package/dist/{EventTransport-BV10D0oG.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
- package/dist/{HookSystem-vimw5D7Z.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
- package/dist/{ResourceRegistry-BVr7PgA0.mjs → ResourceRegistry-DoALUlOO.mjs} +3 -2
- package/dist/{actionPermissions-Dti7ebyv.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +2 -2
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +39 -7
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-__T3EJDA.mjs → authorizationDecision-CMMLWmuZ.mjs} +6 -2
- package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
- package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-BrdYrG_1.mjs} +4 -4
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DFJqHyFs.mjs → caching-BcGmvnHh.mjs} +2 -2
- package/dist/cleanup/index.d.mts +17 -5
- package/dist/cleanup/index.mjs +240 -7
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +3 -3
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-J99LGTJI.mjs → constants-BxCoxmiN.mjs} +1 -1
- package/dist/context/index.mjs +3 -3
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +9 -8
- package/dist/{core-BwwmNSp7.mjs → core-BYKa8wHu.mjs} +3 -3
- package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-D6T0jWhS.mjs} +8 -8
- package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-QLV2PrWP.mjs} +7 -7
- package/dist/{createApp-Bv0m-Q6_.mjs → createApp-zR-DEesr.mjs} +128 -24
- package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
- package/dist/{defineResource-XFPPUu2c.mjs → defineResource-ZhuLonLm.mjs} +297 -23
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +3 -3
- package/dist/{elevation-DNXXxgjl.mjs → elevation-B0tlY8y1.mjs} +4 -4
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-B2AZUJUj.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
- package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
- package/dist/{errors-Bh1KK5ps.mjs → errors-Cd_yy7aW.mjs} +1 -1
- package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
- package/dist/{eventPlugin-Dk_TyXeE.mjs → eventPlugin-Dar8qRaj.mjs} +65 -9
- package/dist/events/index.d.mts +5 -351
- package/dist/events/index.mjs +5 -780
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +1 -1
- package/dist/factory/index.d.mts +20 -5
- package/dist/factory/index.mjs +7 -7
- package/dist/{fastify-augmentation-BppG5bMT.d.mts → fastify-augmentation-DVsTAy6X.d.mts} +3 -3
- package/dist/{fields-DEi65pGj.mjs → fields-CojeUkS4.mjs} +1 -1
- package/dist/{generate-CRSG0jTv.mjs → generate-DiB0nHPR.mjs} +2 -2
- package/dist/{headers-BXjQYbeC.mjs → headers-CveNO9gR.mjs} +2 -2
- package/dist/{health-5XnTa2uo.mjs → health-CasG2A28.mjs} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +45 -1
- package/dist/idempotency/index.mjs +90 -9
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-zq6VrjaB.d.mts → index-B-DvUzsr.d.mts} +12 -2
- package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
- package/dist/{index-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
- package/dist/{index-DXlHwvuE.d.mts → index-CFAJEWCX.d.mts} +18 -5
- package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +13 -12
- package/dist/{init-BbfP8aky.mjs → init-mdJ-v4Xp.mjs} +18 -12
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +2 -2
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +99 -97
- package/dist/integrations/mcp/index.d.mts +12 -3
- package/dist/integrations/mcp/index.mjs +2 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.mjs +3 -3
- package/dist/integrations/webhooks.d.mts +124 -1
- package/dist/integrations/webhooks.mjs +180 -2
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.mjs +1 -1
- package/dist/{interface-BP4IJCBO.mjs → interface-DznJrm9R.mjs} +1 -1
- package/dist/{jsonBody-DDSR2HFl.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
- package/dist/{keys-B-HBTGXl.mjs → keys-WeKvY1dE.mjs} +1 -1
- package/dist/{loadResources-DisEAmkm.mjs → loadResources-Bj-aSsXi.mjs} +2 -2
- package/dist/{loadResourcesFromEntry-D1MJp13f.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
- package/dist/logger/index.mjs +1 -1
- package/dist/{mcpPlugin-CpfVpKHE.mjs → mcpPlugin-DX3PHD7K.mjs} +60 -21
- package/dist/{memory-DsuWnj4T.mjs → memory-qYSB7rMQ.mjs} +2 -2
- package/dist/{metrics-DdQL8syd.mjs → metrics-D7uchLjN.mjs} +2 -2
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-Br81am6m.mjs → module-CwKsgZXp.mjs} +1 -1
- package/dist/{multipartBody-BnkBLN7v.mjs → multipartBody-4w-S3-PU.mjs} +1 -1
- package/dist/{openapi-DIRzvxC0.mjs → openapi-CkV5mQ53.mjs} +6 -6
- package/dist/outbox-admin/index.d.mts +2 -2
- package/dist/outbox-admin/index.mjs +4 -4
- package/dist/outbox-module-C1Bs_FXF.d.mts +382 -0
- package/dist/outbox-module-D1QfHqO4.mjs +846 -0
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +5 -5
- package/dist/{permissions-Dl_m0NCe.mjs → permissions-CP47jdi7.mjs} +25 -6
- package/dist/{pipe-ELHCP3zj.mjs → pipe-BzzoEhHj.mjs} +2 -2
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +2 -2
- package/dist/{pipeline-CPA-jx0M.mjs → pipeline-q69DZzgG.mjs} +6 -6
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +16 -16
- package/dist/plugins/response-cache.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-C2ZkKbsn.mjs → pluralize-B7xf8qZF.mjs} +1 -1
- package/dist/{preloadResources-CDs87fTw.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
- package/dist/{preloadResources-T1hmlTEZ.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +2 -2
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +3 -3
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-Dntd_pUO.mjs → presets-BAPijK_A.mjs} +4 -4
- package/dist/projection-BKM0Ui-s.mjs +42 -0
- package/dist/{queryCachePlugin-Dhps-Ldt.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +49 -7
- package/dist/{rateLimit-C7mmAGs3.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
- package/dist/{rateLimitKey-CccEFBfk.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
- package/dist/{realtime-DVvVZZFz.mjs → realtime-G1TC6Yzc.mjs} +9 -9
- package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
- package/dist/{registerRoute-B_QrjYZS.mjs → registerRoute-Ba8v0PCa.mjs} +4 -4
- package/dist/registry/index.d.mts +3 -3
- package/dist/registry/index.mjs +3 -3
- package/dist/{registry-DqUmzA9W.mjs → registry-CLxCR5LF.mjs} +2 -2
- package/dist/{renewingLease-BpP9wwj7.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
- package/dist/{replyHelpers-DS-P0b32.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
- package/dist/repositoryFilter-BANpz6BX.mjs +78 -0
- package/dist/{requestContext-B6vQF-sy.mjs → requestContext-uYE35JxI.mjs} +1 -1
- package/dist/{rolldown-runtime-BhflRrBs.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
- package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
- package/dist/{schedules-C2Q3uv2d.mjs → schedules-CAcZgia0.mjs} +3 -3
- package/dist/{schemaConverter-Crjvczi_.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
- package/dist/{schemaIR-CnyKK_1j.mjs → schemaIR-CDvclAew.mjs} +2 -2
- package/dist/schemas/index.mjs +1 -1
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.mjs +5 -5
- package/dist/{scope-afz_Ieek.mjs → scope-BaWcqBvr.mjs} +4 -4
- package/dist/{simpleEqualityMatcher-C4KiMWUa.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
- package/dist/{sse-B9P4Y-UI.mjs → sse-Cf0jUHsh.mjs} +5 -5
- package/dist/{sseStream-B3zb_d1h.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
- package/dist/{store-helpers-DmEMHlPQ.mjs → store-helpers-B88p99Z2.mjs} +1 -1
- package/dist/{streaming-DPZdQmDz.mjs → streaming-DsT1B5zJ.mjs} +1 -1
- package/dist/{subscribe-helpers-sp5xTDVA.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
- package/dist/sync/changelog.mjs +1 -1
- package/dist/testing/cleanupStoreContract.d.mts +1 -1
- package/dist/testing/cleanupStoreContract.mjs +3 -1
- package/dist/testing/index.d.mts +24 -12
- package/dist/testing/index.mjs +24 -12
- package/dist/testing/outboxStoreContract.mjs +1 -1
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/{transactionContext-BvLKvOIL.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
- package/dist/{typeGuards-B_5-hKjJ.mjs → typeGuards-jjKJDN13.mjs} +1 -1
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CT5vu6M0.d.mts → types-BcTumAz_.d.mts} +41 -7
- package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
- package/dist/{types-DXYX7dIR.mjs → types-CoqIRrkY.mjs} +4 -4
- package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
- package/dist/usage/index.d.mts +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/{userHelpers-BNsOXzOd.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
- package/dist/utils/index.d.mts +57 -5
- package/dist/utils/index.mjs +12 -11
- package/dist/{utils-BtOha_Sp.mjs → utils-h1V_sT-t.mjs} +38 -7
- package/dist/{validate-PMCPR9El.mjs → validate-CdQlz52F.mjs} +2 -2
- package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
- package/dist/{versioning-CKv2jz_U.mjs → versioning-rZbyLVD-.mjs} +3 -3
- package/package.json +17 -16
- package/skills/arc/SKILL.md +96 -4
- package/skills/arc/references/api-reference.md +19 -0
- package/skills/arc/references/production.md +2 -2
- package/dist/projection-CjHhM5W1.mjs +0 -18
- package/dist/repositoryFilter-DotlxG3L.mjs +0 -45
package/skills/arc/SKILL.md
CHANGED
|
@@ -69,6 +69,14 @@ await app.listen({ port: 8040, host: '0.0.0.0' });
|
|
|
69
69
|
|
|
70
70
|
**Boot order (fixed):** `beforeBoot` → modules resolve → `plugins` → `bootstrap[]` → `resources` → `afterResources` → `onReady`.
|
|
71
71
|
|
|
72
|
+
**`role` — one image, N deployments (2.33).** `createApp({ role })` declares what a process is FOR, so the same codebase ships as api / worker / relay / scheduler without per-deployment builds:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
createApp({ role: process.env.ARC_ROLE as 'api' | 'worker' | 'relay' | 'scheduler' | 'all' })
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`api` and `worker` mount NO module schedule arms; `relay` mounts only `kind: 'relay'` arms (the outbox relay, durable webhook delivery); `scheduler` mounts all of them. Declaring `relay`/`scheduler` together with `resources`/`resourceDir` is BOOT-FATAL — a relay process must not serve routes. Default `all` is the pre-2.33 behaviour, unchanged. `createWorker()` forwards `role`; without one it still runs everything non-HTTP.
|
|
79
|
+
|
|
72
80
|
`beforeBoot()` (2.21) runs before Fastify exists and before module thunks / resource files import — connect the DB there instead of dynamic-import ordering hacks.
|
|
73
81
|
|
|
74
82
|
For async-booted engines, pass `resources` as a factory so it runs after `bootstrap[]`:
|
|
@@ -162,7 +170,7 @@ defineResource({ name: 'health', customRoutesOnly: true, routes: [...] });
|
|
|
162
170
|
defineResource({ name: 'pipeline', adapter, defaultLimit: 200, maxLimit: 500 });
|
|
163
171
|
```
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
A `?limit=` above the cap is **clamped to it**, not rejected (2.33 — see Query parsing). The effective cap rides in the OpenAPI parameter description.
|
|
166
174
|
|
|
167
175
|
## fieldRules
|
|
168
176
|
|
|
@@ -304,12 +312,18 @@ Decorates `app.authenticate` / `app.optionalAuthenticate` / `app.authorize`.
|
|
|
304
312
|
## Authoritative gotchas
|
|
305
313
|
|
|
306
314
|
- **Field-write reject (default).** Requests carrying non-writable fields → 403 with denied list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
|
|
315
|
+
- **Immutable writes are STRIPPED, not refused (default).** `PATCH {"type":"rent"}` on an `immutable` field returns 200 with the value unchanged. Opt into 403: `defineResource({ onImmutableWrite: 'reject' })`, or `ARC_STRICT_IMMUTABLE_WRITES=true`. Separate from `onFieldWriteDenied` because a full-object PATCH echoing an immutable field back UNCHANGED is legitimate, and the sanitizer has no stored document to tell that from a real change — turn it on if you send partial patches.
|
|
316
|
+
- **Log redaction is REPLACED, not merged.** Setting `logger.redact` makes yours the whole policy; arc's defaults stop applying. Superset them: `import { DEFAULT_LOGGER_REDACT_PATHS } from '@classytic/arc/factory'` and spread it into your list, rather than hand-copying (a copy drifts — one host's had lost `*.passwordHash`).
|
|
317
|
+
- **Error disclosure derives from `preset`, falling back to the environment.** `includeStack` and `exposeInternalMessages` are both derived, and host keys MERGE over them — stating one no longer resets the other. Direct `errorHandlerPlugin` registration skips the preset derivation entirely.
|
|
307
318
|
- **multiTenant injects org on UPDATE.** Body `organizationId` is overwritten with caller's scope (closes tenant-hop).
|
|
308
319
|
- **`request.user`** is `Record<string, unknown> | undefined`. Guard with `if (req.user)` on public routes.
|
|
309
320
|
- **Arc reads singular `user.role`** (string, comma-separated, or array). Don't put a plural `roles` field on the model.
|
|
310
321
|
- **`verifySignature(body, …)`** throws on parsed body — pass `req.rawBody`.
|
|
311
322
|
- **MCP tools fail-closed.** A resource action without per-action perm + no `actionPermissions` + no `permissions.update` fallback → throws at tool generation. Declare `allowPublic()` to opt into unauthenticated.
|
|
312
323
|
- **Better Auth roles.** BA stores `member.role = "admin,recruiter"` (comma-separated). Arc splits into `scope.orgRoles = ['admin', 'recruiter']`; `?role=admin` won't match — use `role[like]=admin`.
|
|
324
|
+
- **Read-only repositories refuse write ROUTES at boot (2.33).** Kits' Better Auth overlays are sealed by default (`capabilities.readOnly`) — BA owns hashing, session revocation, org cascades. Mounting `create`/`update`/`delete` over one is boot-fatal: disable those routes, or pass the kit's `unsafeWritable: true` for administrative repair only. Normal identity mutations go through `auth.api`.
|
|
325
|
+
- **Arc closes only stores/transports it BUILT (2.33).** One you hand to `idempotencyPlugin` / `eventPlugin` / `queryCachePlugin` stays yours to close — arc used to close every one, which wiped a co-resident app's live idempotency records.
|
|
326
|
+
- **Idempotency self-checks its store at boot (2.33).** A probe write must read back under its own key and NOT under another; registration throws otherwise. Catches a filter-stripping repository (a pathless Mongoose schema under `strictQuery: true` strips filters to `{}`), which otherwise replays cached responses across keys AND users. `selfCheck: false` to disable; the adapter's per-read identity checks remain.
|
|
313
327
|
|
|
314
328
|
## routeGuards + defineGuard
|
|
315
329
|
|
|
@@ -369,6 +383,28 @@ defineResource({
|
|
|
369
383
|
});
|
|
370
384
|
```
|
|
371
385
|
|
|
386
|
+
**An unknown filter key is DROPPED by default, and dropping WIDENS.** `?filters[status]=pending`
|
|
387
|
+
against a parser that does not whitelist `status` returns 200 with every row unfiltered — the
|
|
388
|
+
caller asked to narrow and got a broader set. Refuse instead:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
new QueryParser({ allowedFilterFields: [...], strictFilterFields: true }) // 400 ValidationError
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Off by default (an unconditional throw would reject requests that work today). Fleet-wide via
|
|
395
|
+
`ARC_STRICT_QUERY_PARAMS=true`; an explicit `false` beats the env. Only the FILTER whitelist —
|
|
396
|
+
a dropped sort or operator degrades ordering, it does not widen the row set.
|
|
397
|
+
|
|
398
|
+
**`?limit` above the cap CLAMPS, it does not 400** (2.33). The parser and `QueryResolver` both
|
|
399
|
+
clamp, and the querystring schema no longer emits `maximum` — it used to reject first, making the
|
|
400
|
+
documented clamp unreachable and turning a benign over-ask into a 400 that callers render as an
|
|
401
|
+
empty list. The cap is in the parameter description instead, so OpenAPI still shows it.
|
|
402
|
+
|
|
403
|
+
**One cap, one owner.** Page size is capped by the parser, the repository's pagination engine and
|
|
404
|
+
arc — lowest wins silently. Arc defers to the parser's `maxLimit` when the host states none, and
|
|
405
|
+
WARNS at boot when the parser allows more than the repository (`pagination-cap-mismatch`).
|
|
406
|
+
Configure both to the same number.
|
|
407
|
+
|
|
372
408
|
## Aggregations
|
|
373
409
|
|
|
374
410
|
`GET /:prefix/aggregations/:name` per entry. Portable `$match → $group → $project → $sort → $limit` against `repo.aggregate(req, options)` — same shape across kits.
|
|
@@ -453,6 +489,31 @@ defineResource({
|
|
|
453
489
|
|
|
454
490
|
POST/PATCH/DELETE bumps resource version. Response header: `x-cache: HIT | STALE | MISS`. `runtime: 'distributed'` requires `stores.queryCache: RedisCacheStore`.
|
|
455
491
|
|
|
492
|
+
## Transactional writes (2.33)
|
|
493
|
+
|
|
494
|
+
`transactional: true` runs the PERSISTENCE step inside `withTransaction`. Write verbs receive the tx-bound repository plus `ctx.uow` — the join point for work outside the repository:
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
defineResource({
|
|
498
|
+
name: 'order',
|
|
499
|
+
adapter,
|
|
500
|
+
transactional: true,
|
|
501
|
+
writes: {
|
|
502
|
+
create: async (data, ctx) => {
|
|
503
|
+
const order = await ctx.repository.create(data); // tx-bound
|
|
504
|
+
await outbox.store(event, { session: ctx.uow?.session }); // SAME transaction
|
|
505
|
+
return order;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
- Hooks stay OUTSIDE the retry — before/after run once; only persistence re-runs.
|
|
512
|
+
- Transient conflicts re-run with jittered backoff; `VersionConflictError` surfaces (409), never retried.
|
|
513
|
+
- **Retry authority is the KIT's.** mongokit declares `transactionRetry: 'managed'` (its driver already retries internally) and is called exactly once; sqlitekit declares `'caller'` and gets the envelope's loop. Arc never stacks a second policy.
|
|
514
|
+
- **BOOT-FATAL** without `withTransaction`, and at REGISTRATION when the repository reports `capabilities.transactions !== true` (standalone Mongo, D1, unconfirmed topology). The capability is checked at registration, not at `defineResource()`, because that usually runs at module import — before `beforeBoot()` connects.
|
|
515
|
+
- `ctx.uow.session` is empty on connection-bound backends (SQLite); the tx-bound repo is the join point there.
|
|
516
|
+
|
|
456
517
|
## Events
|
|
457
518
|
|
|
458
519
|
CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`. Manual publish:
|
|
@@ -464,13 +525,27 @@ await app.events.subscribe('order.*', async (event) => { … });
|
|
|
464
525
|
|
|
465
526
|
Transports: Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, consumer groups, DLQ). Event types live in `@classytic/primitives/events` (`createEvent`, `createChildEvent`, `matchEventPattern`, …); arc re-exports the runtime `MemoryEventTransport` only.
|
|
466
527
|
|
|
467
|
-
**Outbox** (at-least-once via transactional outbox):
|
|
528
|
+
**Outbox** (at-least-once via transactional outbox) — prefer the MODULE, so the store is a slot other modules depend on rather than a value threaded by hand:
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
createApp({ modules: [createOutboxModule({ store: repositoryAsOutboxStore(outboxRepo) })] });
|
|
532
|
+
// Dev: createOutboxModule({ store: new MemoryOutboxStore() })
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
The relay arm is `kind: 'relay'`, so `role: 'relay'` deployments drain it. It resolves the RAW transport, never the `fastify.events` facade (the facade fails open, which would acknowledge rows whose publish failed) — no transport is boot-fatal. **Fencing (2.33):** stores that support `claimPendingFenced` mint a token in the claim CAS; a relay that lost its lease mid-batch cannot ack or re-schedule a row a successor already owns. Feature-detected end to end.
|
|
536
|
+
|
|
537
|
+
**Durable webhooks (2.33)** — delivery survives crash and deploy, using the outbox machinery rather than a second queue:
|
|
468
538
|
|
|
469
539
|
```typescript
|
|
470
|
-
const
|
|
471
|
-
|
|
540
|
+
const store = repositoryAsOutboxStore(deliveryRepo);
|
|
541
|
+
createApp({
|
|
542
|
+
modules: [createDurableWebhookModule({ store })], // relay does the signed POST
|
|
543
|
+
plugins: async (f) => f.register(webhookPlugin, { durable: { store } }), // enqueue, don't POST
|
|
544
|
+
});
|
|
472
545
|
```
|
|
473
546
|
|
|
547
|
+
One row per (event × subscription), deterministic id so an at-least-once upstream enqueues each delivery exactly once. Transient failures (network/timeout/429/5xx) retry with backoff; 4xx dead-letters on the FIRST attempt. `durable` + `retry` together is a registration error.
|
|
548
|
+
|
|
474
549
|
Full event recipes (retry, DLQ, dual-publish warnings, idempotency keys) → [references/events.md](references/events.md).
|
|
475
550
|
|
|
476
551
|
## Hooks
|
|
@@ -652,6 +727,23 @@ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
|
|
|
652
727
|
|
|
653
728
|
`@classytic/arc/usage` — `usagePlugin` decorates `fastify.usage` with per-actor, per-UTC-month counters (`record`/`summary`/`period`/`actorOf`; actor chain org → user → client → ip). Auto-tracks `api.requests` (default on) + `api.egress.bytes` (opt-in); recording is fail-safe (a throwing `UsageStore` never fails a request; `MemoryUsageStore` built in, Redis/kit backends are ~5 lines). Pair with `createApp({ rateLimit: { plan: { resolve, limits: { free: { max: 60 }, enterprise: false }, default: 'free' } } })` for per-plan ceilings — `false` = unlimited, unknown/throwing resolvers fall back to `default` then global `max`, and buckets follow the tenant key chain by default. Caveat: the limiter runs before route auth, so `resolve` sees the raw request.
|
|
654
729
|
|
|
730
|
+
## Runtime capability registry (2.33)
|
|
731
|
+
|
|
732
|
+
Under `runtime: 'distributed'`, subsystems holding process-local state must declare it — the end-of-boot audit FAILS the app on undeclared memory state, naming every violator:
|
|
733
|
+
|
|
734
|
+
```typescript
|
|
735
|
+
import { declareRuntimeCapability } from '@classytic/arc/utils';
|
|
736
|
+
|
|
737
|
+
declareRuntimeCapability(fastify, {
|
|
738
|
+
subsystem: 'billing.sequence-cache',
|
|
739
|
+
durability: 'memory', // 'memory' | 'shared'
|
|
740
|
+
accepted: true, // per-process BY DESIGN (logged, never fatal)
|
|
741
|
+
detail: 'short-TTL cache; correctness from TTL, not shared state',
|
|
742
|
+
});
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
`createApp`'s constructor-time guard only sees what it is passed; state a host wires inside `plugins()` was invisible to it and lived on a checklist. This makes the checklist enforcement. Arc's webhooks plugin declares its default in-memory store.
|
|
746
|
+
|
|
655
747
|
## Adapters
|
|
656
748
|
|
|
657
749
|
Cross-framework adapter contract lives in `@classytic/repo-core/adapter`. Every kit-specific adapter ships from its kit's `/adapter` subpath; arc itself has zero kit-bound adapters.
|
|
@@ -126,8 +126,27 @@ import {
|
|
|
126
126
|
import { tracingPlugin } from '@classytic/arc/plugins/tracing';
|
|
127
127
|
import { auditPlugin } from '@classytic/arc/audit';
|
|
128
128
|
import { idempotencyPlugin } from '@classytic/arc/idempotency';
|
|
129
|
+
// Runtime capability registry (2.33) — declare process-local state so a
|
|
130
|
+
// `runtime: 'distributed'` boot fails on undeclared memory state:
|
|
131
|
+
import { declareRuntimeCapability } from '@classytic/arc/utils';
|
|
129
132
|
```
|
|
130
133
|
|
|
134
|
+
**Idempotency store — the one schema mistake that matters.** Backing the store
|
|
135
|
+
with a repository requires the key path to survive the filter. A Mongoose
|
|
136
|
+
schema with NO declared paths under a global `strictQuery: true` has EVERY
|
|
137
|
+
filter key stripped, so `getOne({ _id: key })` becomes `getOne({})`: one row
|
|
138
|
+
absorbs every key and responses replay across keys AND users. Declare the path
|
|
139
|
+
and pin the option on the schema:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
new Schema({ _id: String }, { strict: false, strictQuery: false, timestamps: false })
|
|
143
|
+
// NOT `new Schema({}, { _id: false, strict: false })`
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The plugin's boot self-check (default on, `selfCheck: false` to disable) probes
|
|
147
|
+
for exactly this and refuses to register; the adapter also throws
|
|
148
|
+
`IdempotencyStoreMisconfiguredError` on any cross-key read at runtime.
|
|
149
|
+
|
|
131
150
|
## Integrations
|
|
132
151
|
|
|
133
152
|
```typescript
|
|
@@ -292,7 +292,7 @@ field masking. Client = plain `EventSource` with `?token=`. No replay — reconn
|
|
|
292
292
|
refetches the list. Choosing: per-user resource views → realtime; bidirectional/geo
|
|
293
293
|
→ `integrations/websocket` (its CRUD bridge is org-coarse — never expose
|
|
294
294
|
row-permissioned resources through it); event firehose → sse. Massive fan-out
|
|
295
|
-
(viral feeds, live scores) → publish once to
|
|
295
|
+
(viral feeds, live scores) → publish once to a dedicated edge fan-out layer
|
|
296
296
|
via `app.events.subscribe(...)`; full tiering guide: docs/production-ops/realtime.mdx.
|
|
297
297
|
|
|
298
298
|
```typescript
|
|
@@ -618,7 +618,7 @@ presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems:
|
|
|
618
618
|
## Compensating Transaction
|
|
619
619
|
|
|
620
620
|
In-process rollback primitive — runs steps in order, compensates in reverse on failure.
|
|
621
|
-
For distributed sagas across services, use
|
|
621
|
+
For distributed sagas across services, use `@classytic/streamline` or a dedicated workflow orchestrator.
|
|
622
622
|
|
|
623
623
|
```typescript
|
|
624
624
|
import { withCompensation } from '@classytic/arc/utils';
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
|
|
2
|
-
import { c as getOrgId, g as getUserId, x as isMember } from "./types-DXYX7dIR.mjs";
|
|
3
|
-
//#region src/scope/projection.ts
|
|
4
|
-
/**
|
|
5
|
-
* Compute the request-scope projection. Returns `undefined` when no
|
|
6
|
-
* scope is attached (public / unscoped routes) so hosts can idiomatically
|
|
7
|
-
* write `ctx.scope?.organizationId` without a double-null check.
|
|
8
|
-
*/
|
|
9
|
-
function buildRequestScopeProjection(scope) {
|
|
10
|
-
if (!scope) return void 0;
|
|
11
|
-
return {
|
|
12
|
-
organizationId: getOrgId(scope),
|
|
13
|
-
userId: getUserId(scope),
|
|
14
|
-
orgRoles: isMember(scope) ? scope.orgRoles : void 0
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
//#endregion
|
|
18
|
-
export { buildRequestScopeProjection as t };
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
|
|
2
|
-
import { arcLog } from "./logger/index.mjs";
|
|
3
|
-
import { policyRecordToFilter } from "@classytic/repo-core/filter";
|
|
4
|
-
//#region src/core/repositoryFilter.ts
|
|
5
|
-
const log = arcLog("repository-filter");
|
|
6
|
-
let warnedUnconvertible = false;
|
|
7
|
-
/** True for a `{ $op: ... }` operator object (all keys `$`-prefixed). */
|
|
8
|
-
function isOperatorObject(value) {
|
|
9
|
-
if (value === null || typeof value !== "object") return false;
|
|
10
|
-
if (Array.isArray(value) || value instanceof Date) return false;
|
|
11
|
-
const keys = Object.keys(value);
|
|
12
|
-
return keys.length > 0 && keys.some((k) => k.startsWith("$"));
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Does this compound filter contain any `$`-operator (top-level logical like
|
|
16
|
-
* `$and`/`$or`, or a field operator object like `{ $in: [...] }`)? If not, it is
|
|
17
|
-
* pure flat equality and portable as-is.
|
|
18
|
-
*/
|
|
19
|
-
function filterHasDollarOperator(record) {
|
|
20
|
-
for (const [key, value] of Object.entries(record)) {
|
|
21
|
-
if (key.startsWith("$")) return true;
|
|
22
|
-
if (isOperatorObject(value)) return true;
|
|
23
|
-
}
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Normalize a compound query/policy filter for the repository layer. Flat
|
|
28
|
-
* equality records pass through unchanged; anything carrying `$`-operators is
|
|
29
|
-
* converted to the portable repo-core {@link Filter} IR so it compiles
|
|
30
|
-
* identically on Mongo, SQLite, Postgres, and custom adapters.
|
|
31
|
-
*/
|
|
32
|
-
function toRepositoryFilter(record) {
|
|
33
|
-
if (!filterHasDollarOperator(record)) return record;
|
|
34
|
-
try {
|
|
35
|
-
return policyRecordToFilter(record);
|
|
36
|
-
} catch (err) {
|
|
37
|
-
if (!warnedUnconvertible) {
|
|
38
|
-
warnedUnconvertible = true;
|
|
39
|
-
log.warn(`A \`\$\`-operator filter could not be normalized to portable Filter IR (operator outside arc's policy dialect). Passing the raw record to the repository — MongoKit handles it; non-Mongo kits may not. (${err instanceof Error ? err.message : String(err)})`);
|
|
40
|
-
}
|
|
41
|
-
return record;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
//#endregion
|
|
45
|
-
export { toRepositoryFilter as t };
|