@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
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BxCoxmiN.mjs";
|
|
3
3
|
import { arcLog } from "./logger/index.mjs";
|
|
4
|
-
import { a as conjoinPolicyFilters } from "./authorizationDecision-
|
|
5
|
-
import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-
|
|
6
|
-
import { n as getUserRoles, t as getUserId } from "./userHelpers-
|
|
7
|
-
import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-
|
|
8
|
-
import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-
|
|
9
|
-
import {
|
|
10
|
-
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-
|
|
11
|
-
import { g as ArcQueryParser, n as scheduleBackground } from "./utils-
|
|
12
|
-
import { t as buildQueryKey } from "./keys-
|
|
4
|
+
import { a as conjoinPolicyFilters } from "./authorizationDecision-CMMLWmuZ.mjs";
|
|
5
|
+
import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-Cd_yy7aW.mjs";
|
|
6
|
+
import { n as getUserRoles, t as getUserId } from "./userHelpers-CWF5pbZD.mjs";
|
|
7
|
+
import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CoqIRrkY.mjs";
|
|
8
|
+
import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Se8VrsaP.mjs";
|
|
9
|
+
import { n as toRepositoryFilter } from "./repositoryFilter-BANpz6BX.mjs";
|
|
10
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CojeUkS4.mjs";
|
|
11
|
+
import { g as ArcQueryParser, n as scheduleBackground } from "./utils-h1V_sT-t.mjs";
|
|
12
|
+
import { t as buildQueryKey } from "./keys-WeKvY1dE.mjs";
|
|
13
|
+
import { retryingTransaction } from "@classytic/repo-core/repository";
|
|
14
|
+
import { isProductionEnv } from "@classytic/primitives/environment";
|
|
13
15
|
//#region src/core/AccessControl.ts
|
|
14
16
|
const log$1 = arcLog("access-control");
|
|
15
17
|
var AccessControl = class {
|
|
@@ -259,9 +261,11 @@ const DEFAULT_FIELD_WRITE_DENIAL_POLICY = "reject";
|
|
|
259
261
|
var BodySanitizer = class {
|
|
260
262
|
schemaOptions;
|
|
261
263
|
onFieldWriteDenied;
|
|
264
|
+
onImmutableWrite;
|
|
262
265
|
constructor(config) {
|
|
263
266
|
this.schemaOptions = config.schemaOptions;
|
|
264
267
|
this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
|
|
268
|
+
this.onImmutableWrite = config.onImmutableWrite ?? (process.env.ARC_STRICT_IMMUTABLE_WRITES === "true" ? "reject" : "strip");
|
|
265
269
|
}
|
|
266
270
|
/**
|
|
267
271
|
* Strip readonly and system-managed fields from request body.
|
|
@@ -276,11 +280,16 @@ var BodySanitizer = class {
|
|
|
276
280
|
const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
|
|
277
281
|
const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
|
|
278
282
|
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
283
|
+
const immutableAttempts = [];
|
|
279
284
|
for (const [field, rules] of Object.entries(fieldRules)) {
|
|
280
285
|
const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
|
|
281
286
|
if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
|
|
282
|
-
if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass)
|
|
287
|
+
if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) {
|
|
288
|
+
if (Object.hasOwn(sanitized, field)) immutableAttempts.push(field);
|
|
289
|
+
delete sanitized[field];
|
|
290
|
+
}
|
|
283
291
|
}
|
|
292
|
+
if (immutableAttempts.length > 0 && this.onImmutableWrite === "reject") throw new ForbiddenError(`Cannot modify immutable field${immutableAttempts.length === 1 ? "" : "s"}: ${immutableAttempts.join(", ")}`);
|
|
284
293
|
if (req) {
|
|
285
294
|
const arcContext = meta ?? req.metadata;
|
|
286
295
|
const scope = arcContext?._scope ?? PUBLIC_SCOPE;
|
|
@@ -363,6 +372,73 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
|
|
|
363
372
|
return out;
|
|
364
373
|
}
|
|
365
374
|
/**
|
|
375
|
+
* Marks a write method ARC installed.
|
|
376
|
+
*
|
|
377
|
+
* The constructor binds `create`/`update`/`delete`, so EVERY controller owns
|
|
378
|
+
* all three and the bound function is a fresh object per instance —
|
|
379
|
+
* `Object.hasOwn` and identity comparison both say nothing about overrides.
|
|
380
|
+
* Provenance does: a class field initialises after `super()` and REPLACES the
|
|
381
|
+
* bound method, mark and all.
|
|
382
|
+
*
|
|
383
|
+
* `Symbol.for` so it still matches across two arc copies in one graph.
|
|
384
|
+
*/
|
|
385
|
+
const ARC_BOUND_WRITE = Symbol.for("arc.boundWriteMethod");
|
|
386
|
+
/**
|
|
387
|
+
* Marks a controller PROTOTYPE whose write methods dispatch declared verbs.
|
|
388
|
+
* `defineResource` refuses `writes` without it: a duck-typed `configure()`
|
|
389
|
+
* proves an options channel exists, not that anything dispatches from it.
|
|
390
|
+
* Lives on the prototype — one mark per class, zero per-instance cost.
|
|
391
|
+
*/
|
|
392
|
+
const ARC_WRITE_VERB_CAPABLE = Symbol.for("arc.writeVerbCapable");
|
|
393
|
+
/** Tag a bound write method as arc's own. Returns the same function. */
|
|
394
|
+
function markArcBoundWrite(fn) {
|
|
395
|
+
Object.defineProperty(fn, ARC_BOUND_WRITE, {
|
|
396
|
+
value: true,
|
|
397
|
+
enumerable: false,
|
|
398
|
+
configurable: true
|
|
399
|
+
});
|
|
400
|
+
return fn;
|
|
401
|
+
}
|
|
402
|
+
/** Did arc install this write method, as opposed to the host replacing it? */
|
|
403
|
+
function isArcBoundWrite(fn) {
|
|
404
|
+
return typeof fn === "function" && Reflect.get(fn, ARC_BOUND_WRITE) === true;
|
|
405
|
+
}
|
|
406
|
+
/** Tag a controller class's prototype as write-verb capable. */
|
|
407
|
+
function markWriteVerbCapable(prototype) {
|
|
408
|
+
Object.defineProperty(prototype, ARC_WRITE_VERB_CAPABLE, {
|
|
409
|
+
value: true,
|
|
410
|
+
enumerable: false,
|
|
411
|
+
configurable: true
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/** Do this controller's write methods dispatch declared write verbs? */
|
|
415
|
+
function isWriteVerbCapable(controller) {
|
|
416
|
+
return typeof controller === "object" && controller !== null && Reflect.get(controller, ARC_WRITE_VERB_CAPABLE) === true;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Which of `ops` did the host override?
|
|
420
|
+
*
|
|
421
|
+
* THE single implementation — the boot-fatal reachability check and the
|
|
422
|
+
* field-protection warn both consume it, so they cannot disagree.
|
|
423
|
+
*
|
|
424
|
+
* Two shapes: a prototype method (found by walking to the capability-marked
|
|
425
|
+
* prototype — every layer between is host-authored) and a class field / ctor
|
|
426
|
+
* assignment (found by provenance, since arc owns an own-property for all
|
|
427
|
+
* three regardless). Arc's own mixins define no write methods; a test pins it.
|
|
428
|
+
*/
|
|
429
|
+
function detectOverriddenWriteSlots(controller, ops) {
|
|
430
|
+
const instance = controller;
|
|
431
|
+
return ops.filter((op) => {
|
|
432
|
+
if (typeof instance[op] === "function" && !isArcBoundWrite(instance[op])) return true;
|
|
433
|
+
let proto = Object.getPrototypeOf(controller);
|
|
434
|
+
while (proto && !Object.hasOwn(proto, ARC_WRITE_VERB_CAPABLE)) {
|
|
435
|
+
if (Object.hasOwn(proto, op)) return true;
|
|
436
|
+
proto = Object.getPrototypeOf(proto);
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
366
442
|
* Resolve the repository primary key for mutation calls.
|
|
367
443
|
*
|
|
368
444
|
* When the resource declares a custom `idField` (slug, jobId, UUID), the
|
|
@@ -634,23 +710,44 @@ const log = arcLog("query");
|
|
|
634
710
|
*/
|
|
635
711
|
let notedDeniedJoins = false;
|
|
636
712
|
function noteDeniedJoins(kind) {
|
|
637
|
-
if (notedDeniedJoins || process.env.NODE_ENV
|
|
713
|
+
if (notedDeniedJoins || isProductionEnv(process.env.NODE_ENV)) return;
|
|
638
714
|
notedDeniedJoins = true;
|
|
639
715
|
log.warn(`client requested ${kind} but no allowlist is configured — DENIED (2.24 default flip; it was allow-all). Set schemaOptions.query.allowedPopulate / allowedLookups to enable client-driven joins. (dev-only note, shown once)`);
|
|
640
716
|
}
|
|
641
717
|
var QueryResolver = class {
|
|
642
718
|
queryParser;
|
|
643
719
|
maxLimit;
|
|
720
|
+
/** Set only when the HOST passed one — it outranks any parser's cap, at any time. */
|
|
721
|
+
explicitMaxLimit;
|
|
644
722
|
defaultLimit;
|
|
645
723
|
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
646
724
|
defaultSort;
|
|
725
|
+
explicitDefaultSort;
|
|
647
726
|
schemaOptions;
|
|
648
727
|
tenantField;
|
|
649
728
|
constructor(config = {}) {
|
|
650
729
|
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
651
|
-
|
|
730
|
+
/**
|
|
731
|
+
* Precedence: explicit config → the PARSER's own cap → framework default.
|
|
732
|
+
*
|
|
733
|
+
* The middle term is the fix. A resource that writes
|
|
734
|
+
* `new QueryParser({ maxLimit: 1000 })` has already answered "how large may a page
|
|
735
|
+
* be?", and arc previously ignored that and applied 100 anyway. Three layers each
|
|
736
|
+
* capping independently, lowest wins, no signal — a chart-of-accounts picker
|
|
737
|
+
* returned 100 of 696 rows and rendered "No accounts found", while the resource
|
|
738
|
+
* AND the repository were both configured for 1000.
|
|
739
|
+
*
|
|
740
|
+
* Note it reads `config.queryParser`, NOT `this.queryParser`. The fallback default
|
|
741
|
+
* parser declares its own generous cap, so deferring to it would silently raise the
|
|
742
|
+
* ceiling from 100 to 1000 on every endpoint that never configured one — a widening
|
|
743
|
+
* nobody asked for. Only a parser the resource EXPLICITLY supplied counts as an
|
|
744
|
+
* answer; a test pins exactly this.
|
|
745
|
+
*/
|
|
746
|
+
this.explicitMaxLimit = config.maxLimit;
|
|
747
|
+
this.maxLimit = config.maxLimit ?? config.queryParser?.maxLimit ?? 100;
|
|
652
748
|
this.defaultLimit = config.defaultLimit ?? 20;
|
|
653
749
|
this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
|
|
750
|
+
this.explicitDefaultSort = config.defaultSort !== void 0 && config.defaultSort !== false;
|
|
654
751
|
this.schemaOptions = config.schemaOptions ?? {};
|
|
655
752
|
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
656
753
|
}
|
|
@@ -662,18 +759,88 @@ var QueryResolver = class {
|
|
|
662
759
|
*/
|
|
663
760
|
setParser(parser) {
|
|
664
761
|
this.queryParser = parser;
|
|
762
|
+
/**
|
|
763
|
+
* The swapped-in parser's cap applies too — swapping is how most resources supply
|
|
764
|
+
* theirs.
|
|
765
|
+
*
|
|
766
|
+
* Deferring only in the constructor was not enough: `setQueryParser()` mutates the
|
|
767
|
+
* resolver in place so captured references stay valid, so a resource that declares
|
|
768
|
+
* `new QueryParser({ maxLimit: 1000 })` arrives HERE, not through `config`. The
|
|
769
|
+
* constructor-only version of this fix looked right, passed its unit tests, and
|
|
770
|
+
* changed nothing at runtime — the chart of accounts still served 100 of 696.
|
|
771
|
+
*
|
|
772
|
+
* An explicit `config.maxLimit` still wins; that is a host decision and outranks a
|
|
773
|
+
* package default either way.
|
|
774
|
+
*/
|
|
775
|
+
if (this.explicitMaxLimit === void 0 && parser.maxLimit !== void 0) this.maxLimit = parser.maxLimit;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* The default sort, but only when the parser permits the field.
|
|
779
|
+
*
|
|
780
|
+
* A resource declaring `allowedSortFields` has stated exactly what may be
|
|
781
|
+
* sorted on. Arc then applied its OWN default of `-createdAt` on top, and
|
|
782
|
+
* mongokit 3.31 REJECTS a sort outside the allowlist rather than dropping it —
|
|
783
|
+
* so every list call on such a resource answered 400, `Blocked sort field not
|
|
784
|
+
* in allowlist: createdAt`. REST and MCP alike; the endpoint was unusable, and
|
|
785
|
+
* nothing the caller sent could avoid it.
|
|
786
|
+
*
|
|
787
|
+
* A general default must never outrank a specific declaration, so the
|
|
788
|
+
* FRAMEWORK default withdraws. A HOST-declared `defaultSort` is left alone —
|
|
789
|
+
* that contradiction is its author's to resolve, and `defineResource` reports
|
|
790
|
+
* it as a boot diagnostic instead of arc silently picking a winner.
|
|
791
|
+
*/
|
|
792
|
+
permittedDefaultSort() {
|
|
793
|
+
const sort = this.defaultSort;
|
|
794
|
+
if (sort === void 0 || this.explicitDefaultSort) return sort;
|
|
795
|
+
const allowed = this.queryParser.allowedSortFields;
|
|
796
|
+
if (!allowed || allowed.length === 0) return sort;
|
|
797
|
+
return sort.split(",").map((s) => s.trim().replace(/^-/, "")).filter(Boolean).every((f) => allowed.includes(f)) ? sort : void 0;
|
|
665
798
|
}
|
|
666
799
|
/**
|
|
667
800
|
* Resolve a request into parsed query options -- ONE parse per request.
|
|
668
801
|
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
669
802
|
*/
|
|
670
803
|
resolve(req, meta) {
|
|
671
|
-
|
|
804
|
+
/**
|
|
805
|
+
* Strip `_policyFilters` BEFORE the parse, not after.
|
|
806
|
+
*
|
|
807
|
+
* **Reachable only when a CLIENT puts it in the URL.** Trusted policy is a
|
|
808
|
+
* request DECORATION (`request._policyFilters`, set by the permission
|
|
809
|
+
* middleware) that `createRequestContext` lifts into `metadata` — it never
|
|
810
|
+
* enters `req.query`, which is Fastify's parsed querystring. So the key
|
|
811
|
+
* arriving here is always user-supplied, always inert (the authoritative
|
|
812
|
+
* copy is read from `arcContext` below), and must simply not reach the
|
|
813
|
+
* parser.
|
|
814
|
+
*
|
|
815
|
+
* The previous `delete` ran on `parsed.filters` — AFTER
|
|
816
|
+
* `queryParser.parse()` — so on the one path that needed it, it could
|
|
817
|
+
* never run: a KIT parser validates every key against the resource's
|
|
818
|
+
* `allowedFilterFields` and throws during the parse, before the cleanup
|
|
819
|
+
* line. Arc's own parser skips the key via `RESERVED_QUERY_PARAMS`, so the
|
|
820
|
+
* two parsers disagreed on the same request: arc ignored a probe, kits
|
|
821
|
+
* 400'd it.
|
|
822
|
+
*
|
|
823
|
+
* Stripping here makes them agree, and agrees with arc. Nothing is
|
|
824
|
+
* weakened — the client's value was already inert — but note the trade:
|
|
825
|
+
* under a kit parser `?_policyFilters=…` used to be a loud 400 and is now
|
|
826
|
+
* silently dropped. The alternative, teaching every kit's reserved-key list
|
|
827
|
+
* about an arc-internal key, spreads one framework's leak across the
|
|
828
|
+
* ecosystem for the same outcome.
|
|
829
|
+
*
|
|
830
|
+
* Copy only when present: the hot path allocates nothing, and `req.query`
|
|
831
|
+
* is never mutated (it is shared with the rest of the request).
|
|
832
|
+
*/
|
|
833
|
+
const rawQuery = req.query;
|
|
834
|
+
let query = rawQuery;
|
|
835
|
+
if (rawQuery && "_policyFilters" in rawQuery) {
|
|
836
|
+
const { _policyFilters: _ignored, ...rest } = rawQuery;
|
|
837
|
+
query = rest;
|
|
838
|
+
}
|
|
839
|
+
const parsed = this.queryParser.parse(query);
|
|
672
840
|
const arcContext = meta ?? req.metadata;
|
|
673
|
-
delete parsed.filters?._policyFilters;
|
|
674
841
|
const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
|
|
675
842
|
const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
676
|
-
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.
|
|
843
|
+
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
|
|
677
844
|
const rawSelect = parsed.select ?? req.query?.select;
|
|
678
845
|
const policyFilters = arcContext?._policyFilters;
|
|
679
846
|
let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
|
|
@@ -838,11 +1005,38 @@ var BaseCrudController = class {
|
|
|
838
1005
|
*/
|
|
839
1006
|
_defaultSort;
|
|
840
1007
|
_onFieldWriteDenied;
|
|
1008
|
+
_onImmutableWrite;
|
|
1009
|
+
/**
|
|
1010
|
+
* Domain commands bound to the write slots (`ResourceWrites`). Consulted at
|
|
1011
|
+
* the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
|
|
1012
|
+
* injection, actor stamp, hook sandwich) runs exactly as it does without
|
|
1013
|
+
* one, which is the entire point of the seam.
|
|
1014
|
+
*/
|
|
1015
|
+
_writes;
|
|
1016
|
+
/** Transactional write envelope — see `ResourceConfig.transactional`. */
|
|
1017
|
+
_transactional = false;
|
|
841
1018
|
constructor(repository, options = {}) {
|
|
842
1019
|
this.repository = repository;
|
|
843
1020
|
this.schemaOptions = options.schemaOptions ?? {};
|
|
844
1021
|
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
845
|
-
|
|
1022
|
+
/**
|
|
1023
|
+
* The parser's cap is consulted BEFORE the framework default.
|
|
1024
|
+
*
|
|
1025
|
+
* A resource that hands us a parser has already stated how large a page it
|
|
1026
|
+
* can serve — a bounded catalog like a chart of accounts declares 1000 so
|
|
1027
|
+
* pickers can read it whole. Defaulting straight to 100 here silently
|
|
1028
|
+
* overrode that: the resource asked for 1000, `QueryResolver` received a
|
|
1029
|
+
* `maxLimit` of 100 that was indistinguishable from a host deliberately
|
|
1030
|
+
* choosing 100, and the list came back truncated with a `200`. be-prod
|
|
1031
|
+
* served 100 of 696 accounts that way, and every account picker in the
|
|
1032
|
+
* product was short by 85% with nothing anywhere to report it.
|
|
1033
|
+
*
|
|
1034
|
+
* Order matters and is the whole point: an explicit `options.maxLimit` from
|
|
1035
|
+
* the host still wins over the parser, and the parser wins over this
|
|
1036
|
+
* framework floor. A general default must only ever fill a gap — never
|
|
1037
|
+
* outrank something specific that was actually stated.
|
|
1038
|
+
*/
|
|
1039
|
+
this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
|
|
846
1040
|
this.defaultLimit = options.defaultLimit ?? 20;
|
|
847
1041
|
this.resourceName = options.resourceName;
|
|
848
1042
|
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
@@ -852,6 +1046,9 @@ var BaseCrudController = class {
|
|
|
852
1046
|
if (options.presetFields) this._presetFields = options.presetFields;
|
|
853
1047
|
this._defaultSort = options.defaultSort;
|
|
854
1048
|
this._onFieldWriteDenied = options.onFieldWriteDenied;
|
|
1049
|
+
this._onImmutableWrite = options.onImmutableWrite;
|
|
1050
|
+
this._writes = options.writes;
|
|
1051
|
+
this._transactional = options.transactional === true;
|
|
855
1052
|
this.accessControl = new AccessControl({
|
|
856
1053
|
tenantField: this.tenantField,
|
|
857
1054
|
idField: this.idField,
|
|
@@ -859,7 +1056,8 @@ var BaseCrudController = class {
|
|
|
859
1056
|
});
|
|
860
1057
|
this.bodySanitizer = new BodySanitizer({
|
|
861
1058
|
schemaOptions: this.schemaOptions,
|
|
862
|
-
onFieldWriteDenied: this._onFieldWriteDenied
|
|
1059
|
+
onFieldWriteDenied: this._onFieldWriteDenied,
|
|
1060
|
+
onImmutableWrite: this._onImmutableWrite
|
|
863
1061
|
});
|
|
864
1062
|
this.queryResolver = new QueryResolver({
|
|
865
1063
|
queryParser: this.queryParser,
|
|
@@ -871,9 +1069,21 @@ var BaseCrudController = class {
|
|
|
871
1069
|
});
|
|
872
1070
|
this.list = this.list.bind(this);
|
|
873
1071
|
this.get = this.get.bind(this);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1072
|
+
/**
|
|
1073
|
+
* The write methods are bound here, which means EVERY instance carries an
|
|
1074
|
+
* own `create`/`update`/`delete` property — so "does it have an own
|
|
1075
|
+
* property" cannot distinguish arc's binding from a host's override.
|
|
1076
|
+
*
|
|
1077
|
+
* Marking what arc installed makes the difference observable. A class-field
|
|
1078
|
+
* override (`update = async (req) => …`) initialises AFTER `super()`
|
|
1079
|
+
* returns and therefore REPLACES this bound method outright — the mark goes
|
|
1080
|
+
* with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
|
|
1081
|
+
* A prototype-method override binds through here and stays marked; that
|
|
1082
|
+
* shape is caught by the prototype comparison instead.
|
|
1083
|
+
*/
|
|
1084
|
+
this.create = markArcBoundWrite(this.create.bind(this));
|
|
1085
|
+
this.update = markArcBoundWrite(this.update.bind(this));
|
|
1086
|
+
this.delete = markArcBoundWrite(this.delete.bind(this));
|
|
877
1087
|
}
|
|
878
1088
|
/**
|
|
879
1089
|
* Swap the controller's query parser. Mutates the existing `QueryResolver`
|
|
@@ -939,6 +1149,10 @@ var BaseCrudController = class {
|
|
|
939
1149
|
rebuildBodySanitizer = true;
|
|
940
1150
|
rebuildQueryResolver = true;
|
|
941
1151
|
}
|
|
1152
|
+
if (options.onImmutableWrite !== void 0) {
|
|
1153
|
+
this._onImmutableWrite = options.onImmutableWrite;
|
|
1154
|
+
rebuildBodySanitizer = true;
|
|
1155
|
+
}
|
|
942
1156
|
if (options.onFieldWriteDenied !== void 0) {
|
|
943
1157
|
this._onFieldWriteDenied = options.onFieldWriteDenied;
|
|
944
1158
|
rebuildBodySanitizer = true;
|
|
@@ -958,6 +1172,8 @@ var BaseCrudController = class {
|
|
|
958
1172
|
}
|
|
959
1173
|
if (options.cache !== void 0) this._cacheConfig = options.cache;
|
|
960
1174
|
if (options.presetFields !== void 0) this._presetFields = options.presetFields;
|
|
1175
|
+
if (options.writes !== void 0) this._writes = options.writes;
|
|
1176
|
+
if (options.transactional !== void 0) this._transactional = options.transactional === true;
|
|
961
1177
|
if (rebuildAccessControl) this.accessControl = new AccessControl({
|
|
962
1178
|
tenantField: this.tenantField,
|
|
963
1179
|
idField: this.idField,
|
|
@@ -965,7 +1181,8 @@ var BaseCrudController = class {
|
|
|
965
1181
|
});
|
|
966
1182
|
if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
|
|
967
1183
|
schemaOptions: this.schemaOptions,
|
|
968
|
-
onFieldWriteDenied: this._onFieldWriteDenied
|
|
1184
|
+
onFieldWriteDenied: this._onFieldWriteDenied,
|
|
1185
|
+
onImmutableWrite: this._onImmutableWrite
|
|
969
1186
|
});
|
|
970
1187
|
if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
|
|
971
1188
|
queryParser: this.queryParser,
|
|
@@ -1105,6 +1322,52 @@ var BaseCrudController = class {
|
|
|
1105
1322
|
return executeHookedOp(this.hookOpContext(req), args, executor);
|
|
1106
1323
|
}
|
|
1107
1324
|
/**
|
|
1325
|
+
* Run a write's PERSISTENCE step, transactionally when the resource asked.
|
|
1326
|
+
*
|
|
1327
|
+
* Plain mode hands `fn` the live repository. Transactional mode runs it
|
|
1328
|
+
* through repo-core's `retryingTransaction`: the callback receives the
|
|
1329
|
+
* TX-BOUND repository (so declared verbs and repository calls join the same
|
|
1330
|
+
* transaction), transient conflicts re-run `fn` with jittered bounded
|
|
1331
|
+
* backoff, everything else — including `VersionConflictError` — surfaces
|
|
1332
|
+
* once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
|
|
1333
|
+
* after-hooks run post-commit, which they already do by running after this.
|
|
1334
|
+
*/
|
|
1335
|
+
runWritePersistence(fn) {
|
|
1336
|
+
if (!this._transactional) return fn(this.repository);
|
|
1337
|
+
return retryingTransaction(this.repository, (txRepo, uow) => fn(txRepo, uow));
|
|
1338
|
+
}
|
|
1339
|
+
/** Assemble the `WriteContext` a `create` verb receives. */
|
|
1340
|
+
writeContext(req, repo = this.repository, uow) {
|
|
1341
|
+
return {
|
|
1342
|
+
req,
|
|
1343
|
+
repository: repo,
|
|
1344
|
+
...uow !== void 0 ? { uow } : {}
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
|
|
1349
|
+
*
|
|
1350
|
+
* `existing` is the document arc ALREADY loaded to run its permission and
|
|
1351
|
+
* tenant checks — `loadMutableTarget` throws before any verb runs when it
|
|
1352
|
+
* cannot produce one, which is why the context types it required. Handing
|
|
1353
|
+
* it over rather than letting the verb re-fetch is deliberate: a second
|
|
1354
|
+
* read can observe a different document than the one the request was
|
|
1355
|
+
* authorised against.
|
|
1356
|
+
*
|
|
1357
|
+
* `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
|
|
1358
|
+
* see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
|
|
1359
|
+
* output.
|
|
1360
|
+
*/
|
|
1361
|
+
mutationWriteContext(req, id, existing, repo = this.repository, uow) {
|
|
1362
|
+
return {
|
|
1363
|
+
req,
|
|
1364
|
+
repository: repo,
|
|
1365
|
+
id,
|
|
1366
|
+
existing,
|
|
1367
|
+
...uow !== void 0 ? { uow } : {}
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1108
1371
|
* Run `executeAfter` for the given op + data. No-op when hooks aren't
|
|
1109
1372
|
* wired or `resourceName` isn't set. Caller passes the data shape it
|
|
1110
1373
|
* wants downstream after-handlers to receive — typically the result for
|
|
@@ -1340,11 +1603,21 @@ var BaseCrudController = class {
|
|
|
1340
1603
|
const item = await this.runHookedOpUntilResult(req, {
|
|
1341
1604
|
op: "create",
|
|
1342
1605
|
input: data
|
|
1343
|
-
}, async (processed) => this.
|
|
1606
|
+
}, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.create ? this._writes.create(processed, this.writeContext(req, repo, uow)) : repo.create(processed, {
|
|
1344
1607
|
user,
|
|
1345
1608
|
context: arcContext,
|
|
1346
1609
|
...this.tenantRepoOptions(req)
|
|
1347
|
-
}));
|
|
1610
|
+
})));
|
|
1611
|
+
/**
|
|
1612
|
+
* A create COMMAND must return the created document — enforced, because
|
|
1613
|
+
* the alternative is quietly worse than any failure: a `201` carrying
|
|
1614
|
+
* `undefined` data, after-hooks (audit, events, cache invalidation) fed a
|
|
1615
|
+
* non-document, and a client that believes the write happened with no way
|
|
1616
|
+
* to address what it created. TypeScript types the contract, but arc is a
|
|
1617
|
+
* JavaScript runtime framework and a JS command returning nothing is one
|
|
1618
|
+
* missing `return` away.
|
|
1619
|
+
*/
|
|
1620
|
+
if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: "WRITE_VERB_CONTRACT_VIOLATION" });
|
|
1348
1621
|
await this.runAfterHook(req, "create", item);
|
|
1349
1622
|
return {
|
|
1350
1623
|
data: item,
|
|
@@ -1367,15 +1640,27 @@ var BaseCrudController = class {
|
|
|
1367
1640
|
op: "update",
|
|
1368
1641
|
input: data,
|
|
1369
1642
|
meta: hookMeta
|
|
1370
|
-
}, async (processed) => this.
|
|
1643
|
+
}, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.update ? this._writes.update(repoId, processed, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.update(repoId, processed, {
|
|
1371
1644
|
user,
|
|
1372
1645
|
context: arcContext,
|
|
1373
1646
|
...this.tenantRepoOptions(req)
|
|
1374
|
-
}));
|
|
1375
|
-
|
|
1376
|
-
|
|
1647
|
+
})));
|
|
1648
|
+
/**
|
|
1649
|
+
* A declared verb signals "not found" by THROWING, so reaching this line
|
|
1650
|
+
* means the command succeeded. Translating its `void` return into a 404
|
|
1651
|
+
* would report failure for a write that happened — the repository's
|
|
1652
|
+
* `null`-means-miss contract is the repository's, not a domain command's.
|
|
1653
|
+
* Re-read so the response still carries the document.
|
|
1654
|
+
*/
|
|
1655
|
+
const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
|
|
1656
|
+
user,
|
|
1657
|
+
context: arcContext,
|
|
1658
|
+
...this.tenantRepoOptions(req)
|
|
1659
|
+
}) : item;
|
|
1660
|
+
if (!resolved) this.throwNotFound("NOT_FOUND");
|
|
1661
|
+
await this.runAfterHook(req, "update", resolved, hookMeta);
|
|
1377
1662
|
return {
|
|
1378
|
-
data:
|
|
1663
|
+
data: resolved,
|
|
1379
1664
|
status: 200,
|
|
1380
1665
|
meta: { message: "Updated successfully" }
|
|
1381
1666
|
};
|
|
@@ -1391,15 +1676,15 @@ var BaseCrudController = class {
|
|
|
1391
1676
|
input: existing,
|
|
1392
1677
|
meta: hookMeta,
|
|
1393
1678
|
pipeProcessedData: false
|
|
1394
|
-
}, async () => this.
|
|
1679
|
+
}, async () => this.runWritePersistence((repo, uow) => this._writes?.delete ? this._writes.delete(repoId, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.delete(repoId, {
|
|
1395
1680
|
user,
|
|
1396
1681
|
context: arcContext,
|
|
1397
1682
|
...this.tenantRepoOptions(req),
|
|
1398
1683
|
...deleteMode ? { mode: deleteMode } : {}
|
|
1399
|
-
}));
|
|
1400
|
-
if (!result) this.throwNotFound("NOT_FOUND");
|
|
1684
|
+
})));
|
|
1685
|
+
if (!result && !this._writes?.delete) this.throwNotFound("NOT_FOUND");
|
|
1401
1686
|
await this.runAfterHook(req, "delete", existing, hookMeta);
|
|
1402
|
-
const deleteResult = result;
|
|
1687
|
+
const deleteResult = result ?? {};
|
|
1403
1688
|
return {
|
|
1404
1689
|
data: {
|
|
1405
1690
|
message: deleteResult.message || "Deleted successfully",
|
|
@@ -1410,6 +1695,15 @@ var BaseCrudController = class {
|
|
|
1410
1695
|
};
|
|
1411
1696
|
}
|
|
1412
1697
|
};
|
|
1698
|
+
/**
|
|
1699
|
+
* This class's write methods dispatch declared write verbs (`_writes`), so
|
|
1700
|
+
* `defineResource` may accept a `writes` declaration for controllers built on
|
|
1701
|
+
* it. One mark on the prototype — inherited by every subclass and mixin
|
|
1702
|
+
* composition, zero per-instance cost. A duck-typed `configure()` is NOT this
|
|
1703
|
+
* proof: it shows an options channel exists, not that anything dispatches
|
|
1704
|
+
* `writes` out of it.
|
|
1705
|
+
*/
|
|
1706
|
+
markWriteVerbCapable(BaseCrudController.prototype);
|
|
1413
1707
|
//#endregion
|
|
1414
1708
|
//#region src/core/mixins/bulk.ts
|
|
1415
1709
|
/**
|
|
@@ -1752,4 +2046,4 @@ function TreeMixin(Base) {
|
|
|
1752
2046
|
*/
|
|
1753
2047
|
var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
|
|
1754
2048
|
//#endregion
|
|
1755
|
-
export { BulkMixin as a, collectReadBlockedFields as c,
|
|
2049
|
+
export { BulkMixin as a, collectReadBlockedFields as c, isWriteVerbCapable as d, BodySanitizer as f, SlugMixin as i, isFieldReadable as l, AccessControl as m, TreeMixin as n, BaseCrudController as o, DEFAULT_FIELD_WRITE_DENIAL_POLICY as p, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, detectOverriddenWriteSlots as u };
|
|
@@ -4,6 +4,21 @@ import { DeadLetteredEvent, DomainEvent, DomainEvent as DomainEvent$1, EventHand
|
|
|
4
4
|
interface MemoryEventTransportOptions {
|
|
5
5
|
/** Logger for error/warning messages (default: console) */
|
|
6
6
|
logger?: EventLogger;
|
|
7
|
+
/**
|
|
8
|
+
* What a THROWING subscriber does to `publish()`.
|
|
9
|
+
*
|
|
10
|
+
* - `'log'` (default) — logged, `publish()` still resolves. The
|
|
11
|
+
* fire-and-forget bus contract: one broken analytics subscriber must not
|
|
12
|
+
* fail a publisher's request.
|
|
13
|
+
* - `'throw'` — REJECTS with an `AggregateError` after running every
|
|
14
|
+
* handler. Required for at-least-once PROCESSING: with this transport
|
|
15
|
+
* `publish()` IS the handling, so a swallowed error reads to an outbox
|
|
16
|
+
* relay as delivery and the row is acknowledged, never retried.
|
|
17
|
+
*
|
|
18
|
+
* Retry redelivers to ALL matching handlers (no per-handler cursor), so
|
|
19
|
+
* handlers must be idempotent. Every handler runs before the rejection.
|
|
20
|
+
*/
|
|
21
|
+
onHandlerError?: "log" | "throw";
|
|
7
22
|
}
|
|
8
23
|
/**
|
|
9
24
|
* In-memory event transport (default).
|
|
@@ -16,6 +31,7 @@ declare class MemoryEventTransport implements EventTransport {
|
|
|
16
31
|
readonly name = "memory";
|
|
17
32
|
private handlers;
|
|
18
33
|
private logger;
|
|
34
|
+
private onHandlerError;
|
|
19
35
|
constructor(options?: MemoryEventTransportOptions);
|
|
20
36
|
publish(event: DomainEvent): Promise<void>;
|
|
21
37
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
2
|
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
3
3
|
//#region src/events/EventTransport.ts
|
|
4
4
|
/**
|
|
@@ -45,6 +45,29 @@ import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
|
45
45
|
* public `events/index.ts` barrel stays clean.
|
|
46
46
|
*/
|
|
47
47
|
/**
|
|
48
|
+
* Decorator key holding the RAW `EventTransport` an app registered.
|
|
49
|
+
*
|
|
50
|
+
* `fastify.events` is a request-facing FACADE — different signature
|
|
51
|
+
* (`publish(type, payload, meta?)`) and fail-open by design — so machinery
|
|
52
|
+
* that must observe publish failure (the outbox relay) resolves the transport
|
|
53
|
+
* through this key instead. `Symbol.for` so it still matches across two arc
|
|
54
|
+
* copies in one dependency graph; deliberately NOT on the public
|
|
55
|
+
* `EventsDecorator` surface.
|
|
56
|
+
*/
|
|
57
|
+
const ARC_EVENT_TRANSPORT = Symbol.for("arc.eventTransport");
|
|
58
|
+
/**
|
|
59
|
+
* Read the raw transport an app registered, or `undefined` when the events
|
|
60
|
+
* plugin was never registered (or predates this key).
|
|
61
|
+
*
|
|
62
|
+
* Callers decide what absence means: the outbox treats it as "no transport",
|
|
63
|
+
* which is a boot-time error rather than a silent no-op relay.
|
|
64
|
+
*/
|
|
65
|
+
function resolveEventTransport(fastify) {
|
|
66
|
+
if (typeof fastify !== "object" || fastify === null) return void 0;
|
|
67
|
+
const found = Reflect.get(fastify, ARC_EVENT_TRANSPORT);
|
|
68
|
+
return typeof found === "object" && found !== null ? found : void 0;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
48
71
|
* In-memory event transport (default).
|
|
49
72
|
*
|
|
50
73
|
* Events are delivered synchronously within the process. Not suitable for
|
|
@@ -55,17 +78,31 @@ var MemoryEventTransport = class {
|
|
|
55
78
|
name = "memory";
|
|
56
79
|
handlers = /* @__PURE__ */ new Map();
|
|
57
80
|
logger;
|
|
81
|
+
onHandlerError;
|
|
58
82
|
constructor(options) {
|
|
59
83
|
this.logger = options?.logger ?? console;
|
|
84
|
+
this.onHandlerError = options?.onHandlerError ?? "log";
|
|
60
85
|
}
|
|
61
86
|
async publish(event) {
|
|
62
87
|
const allHandlers = /* @__PURE__ */ new Set();
|
|
63
88
|
for (const [pattern, handlers] of this.handlers) if (matchEventPattern(pattern, event.type)) for (const h of handlers) allHandlers.add(h);
|
|
89
|
+
const failures = [];
|
|
64
90
|
for (const handler of allHandlers) try {
|
|
65
91
|
await handler(event);
|
|
66
92
|
} catch (err) {
|
|
67
93
|
this.logger.error(`[EventTransport] Handler error for ${event.type}:`, err);
|
|
94
|
+
failures.push(err);
|
|
68
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Rejecting is what lets an OUTBOX see the failure. With this transport,
|
|
98
|
+
* `publish()` IS the handling — synchronous and in-process — so resolving
|
|
99
|
+
* after a handler threw tells the relay the event was delivered, and the
|
|
100
|
+
* row is acknowledged and never retried. Measured: handler throws once,
|
|
101
|
+
* `relayBatch()` reports `relayed: 1, publishFailed: 0`, and the next tick
|
|
102
|
+
* finds nothing. A broker-backed transport has no such ambiguity — publish
|
|
103
|
+
* means "durably handed off" and redelivery is the consumer group's job.
|
|
104
|
+
*/
|
|
105
|
+
if (failures.length > 0 && this.onHandlerError === "throw") throw new AggregateError(failures, `[EventTransport] ${failures.length} handler(s) failed for ${event.type}`);
|
|
69
106
|
}
|
|
70
107
|
/**
|
|
71
108
|
* Reference `publishMany` implementation — delegates to `publish()` in order.
|
|
@@ -101,4 +138,4 @@ var MemoryEventTransport = class {
|
|
|
101
138
|
}
|
|
102
139
|
};
|
|
103
140
|
//#endregion
|
|
104
|
-
export {
|
|
141
|
+
export { resolveEventTransport as i, MemoryEventTransport as n, createEvent as r, ARC_EVENT_TRANSPORT as t };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { t as buildRequestScopeProjection } from "./projection-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as buildRequestScopeProjection } from "./projection-BKM0Ui-s.mjs";
|
|
3
3
|
//#region src/hooks/HookSystem.ts
|
|
4
4
|
/**
|
|
5
5
|
* Hook System
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import "./constants-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import "./constants-BxCoxmiN.mjs";
|
|
3
3
|
//#region src/registry/ResourceRegistry.ts
|
|
4
4
|
/**
|
|
5
5
|
* Resource Registry
|
|
@@ -60,6 +60,7 @@ var ResourceRegistry = class {
|
|
|
60
60
|
summary: r.summary,
|
|
61
61
|
description: r.description,
|
|
62
62
|
permissions: r.permissions,
|
|
63
|
+
capability: r.capability,
|
|
63
64
|
raw: r.rawHandler !== void 0,
|
|
64
65
|
schema: r.schema
|
|
65
66
|
})),
|