@classytic/arc 2.31.0 → 2.33.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-De7GqHck.mjs → BaseController-CaQC5EXX.mjs} +298 -35
- package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
- package/dist/{EventTransport-9mlTgqzE.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
- package/dist/{HookSystem-BcmPyq5I.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
- package/dist/{ResourceRegistry-qq0mn2s9.mjs → ResourceRegistry-DoALUlOO.mjs} +18 -15
- package/dist/{actionPermissions-BE5NwaQk.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 +44 -11
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-ChvUQGG1.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
- package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
- package/dist/{buildHandler-CDrCJzmB.mjs → buildHandler-DLE9xX-V.mjs} +6 -5
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-BFHA6Scf.mjs → caching-BcGmvnHh.mjs} +2 -2
- package/dist/cleanup/index.d.mts +52 -18
- package/dist/cleanup/index.mjs +281 -32
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +4 -6
- 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-D3Dv4LAV.mjs → constants-BxCoxmiN.mjs} +1 -1
- package/dist/context/index.mjs +3 -3
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +7 -7
- package/dist/{core-BGpIxINN.mjs → core-dxhcHhUD.mjs} +3 -3
- package/dist/{createActionRouter-CDXEdasl.mjs → createActionRouter-DVpoA0do.mjs} +14 -11
- package/dist/{createAggregationRouter-KrQDzx-k.mjs → createAggregationRouter-B_B48JZ-.mjs} +10 -8
- package/dist/{createApp-Df89BEni.mjs → createApp-CLGegqiG.mjs} +287 -62
- package/dist/{defineAggregation-DbPPS8RI.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
- package/dist/{defineResource-Bv6EtN1x.mjs → defineResource-DR9vsUYy.mjs} +318 -35
- 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-D2l1mh4g.mjs → elevation-B0tlY8y1.mjs} +4 -4
- package/dist/encryption/index.mjs +5 -4
- package/dist/{errorHandler-C5NeD6bT.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
- package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
- package/dist/{errors-Cj42AntO.mjs → errors-Cd_yy7aW.mjs} +1 -1
- package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
- package/dist/{eventPlugin-CkffEihm.mjs → eventPlugin-Dar8qRaj.mjs} +68 -11
- package/dist/events/index.d.mts +18 -297
- package/dist/events/index.mjs +7 -758
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +14 -10
- 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 +131 -8
- package/dist/fastify-augmentation-DVsTAy6X.d.mts +179 -0
- package/dist/{fields-CB5Cuzk9.mjs → fields-CojeUkS4.mjs} +6 -11
- package/dist/{generate-EcA6ui4i.mjs → generate-DiB0nHPR.mjs} +2 -2
- package/dist/{headers-3zvE0rM3.mjs → headers-CveNO9gR.mjs} +2 -2
- package/dist/{health-DjZYjM7H.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 +95 -11
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-CEJNZFtX.d.mts → index-B-DvUzsr.d.mts} +82 -3
- package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
- package/dist/{index-Bc2ox8_U.d.mts → index-Brin0ODU.d.mts} +4 -4
- package/dist/{index-CFThn1K9.d.mts → index-C5NnMEKF.d.mts} +70 -4
- package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +11 -11
- package/dist/{init-BEiNZBsJ.mjs → init-mdJ-v4Xp.mjs} +24 -16
- 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 +100 -98
- 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 +38 -35
- package/dist/{interface-DYzM0q-u.mjs → interface-DznJrm9R.mjs} +1 -1
- package/dist/{jsonBody-BYbLDi5D.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
- package/dist/{keys-C0_pa1tY.mjs → keys-WeKvY1dE.mjs} +1 -1
- package/dist/{loadResources-yawi9tAE.mjs → loadResources-Bj-aSsXi.mjs} +4 -3
- package/dist/{loadResourcesFromEntry-BAjYZonx.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
- package/dist/logger/index.mjs +1 -1
- package/dist/{mcpPlugin-B_es0hiF.mjs → mcpPlugin-aJg5TqHi.mjs} +76 -36
- package/dist/{memory-DR6wpa9F.mjs → memory-qYSB7rMQ.mjs} +5 -5
- package/dist/{metrics-CUdw_wLB.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 +3 -3
- package/dist/{module-0pFJA6YW.mjs → module-CwKsgZXp.mjs} +1 -1
- package/dist/{multipartBody-wOgj_fnj.mjs → multipartBody-4w-S3-PU.mjs} +3 -3
- package/dist/{openapi-B2L4js1v.mjs → openapi-BN5cQaO2.mjs} +6 -6
- package/dist/outbox-admin/index.d.mts +85 -0
- package/dist/outbox-admin/index.mjs +115 -0
- package/dist/outbox-module-CXB79bvt.mjs +839 -0
- package/dist/outbox-module-D-VFq37T.d.mts +368 -0
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +6 -6
- package/dist/{permissions-L--QDAir.mjs → permissions-BAfwgBxc.mjs} +120 -8
- package/dist/{pipe-DRDNsI2T.mjs → pipe-BzzoEhHj.mjs} +3 -5
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +2 -2
- package/dist/{pipeline-UPQFatit.mjs → pipeline-q69DZzgG.mjs} +17 -15
- 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-O0FZgESl.mjs → pluralize-B7xf8qZF.mjs} +1 -1
- package/dist/{preloadResources-Dz6BjzM6.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
- package/dist/{preloadResources-CyoJMSiO.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +6 -6
- 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-C4-g4_4P.mjs → presets-D-h0uWtP.mjs} +6 -5
- package/dist/projection-BKM0Ui-s.mjs +42 -0
- package/dist/{queryCachePlugin-BZr8v46E.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +50 -8
- package/dist/{rateLimit-CMPY5Qms.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
- package/dist/{rateLimitKey-Dn5JNmkj.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
- package/dist/{realtime-C5NoxWFY.mjs → realtime-CTkv3O59.mjs} +10 -10
- package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
- package/dist/{registerRoute-CBN84fc7.mjs → registerRoute-DNnd3vkz.mjs} +10 -8
- package/dist/registry/index.d.mts +3 -3
- package/dist/registry/index.mjs +3 -3
- package/dist/{registry-4213U4zb.mjs → registry-CLxCR5LF.mjs} +2 -2
- package/dist/{renewingLease-Dll8mGgc.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
- package/dist/{replyHelpers-DIiZKGUt.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
- package/dist/{repositoryFilter-f9eAfq_l.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
- package/dist/{requestContext-2TxXOJ7T.mjs → requestContext-uYE35JxI.mjs} +1 -1
- package/dist/{rolldown-runtime-CUsjNX9R.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
- package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
- package/dist/{schedules-DdtlICTf.mjs → schedules-CAcZgia0.mjs} +3 -3
- package/dist/{schemaConverter-D--wdJM-.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
- package/dist/{schemaIR-Bm2enbnh.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-DHx5Fous.mjs → scope-BaWcqBvr.mjs} +4 -4
- package/dist/{simpleEqualityMatcher-DjQJ7FKA.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
- package/dist/{sse-CUTo9y0X.mjs → sse-Cf0jUHsh.mjs} +7 -6
- package/dist/{sseStream-B_5XcVVd.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
- package/dist/{store-helpers-D1MP_gRG.mjs → store-helpers-B88p99Z2.mjs} +1 -1
- package/dist/{streaming-PSZwi8JV.mjs → streaming-DsT1B5zJ.mjs} +1 -1
- package/dist/{subscribe-helpers-C0Tb2aUp.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 +12 -6
- package/dist/testing/index.d.mts +52 -13
- package/dist/testing/index.mjs +110 -40
- package/dist/testing/outboxStoreContract.d.mts +11 -2
- package/dist/testing/outboxStoreContract.mjs +110 -21
- package/dist/testing/storageContract.mjs +11 -6
- package/dist/{transactionContext-dAfBVUrk.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
- package/dist/{typeGuards-B0ryPo3I.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-CBgvkp8O.d.mts → types-BcTumAz_.d.mts} +128 -18
- package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
- package/dist/{types-Cys0ko9Z.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-ChYJ6CdK.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
- package/dist/utils/index.d.mts +57 -5
- package/dist/utils/index.mjs +12 -11
- package/dist/{utils-vL8BrDE2.mjs → utils-h1V_sT-t.mjs} +38 -7
- package/dist/{validate-_KbifpHF.mjs → validate-CdQlz52F.mjs} +2 -2
- package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
- package/dist/{versioning-CHDTor3P.mjs → versioning-rZbyLVD-.mjs} +4 -4
- package/package.json +31 -20
- 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/fastify-augmentation-zh0_AYHa.d.mts +0 -85
- package/dist/projection-BHw6xlOQ.mjs +0 -18
|
@@ -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 { t as toRepositoryFilter } from "./repositoryFilter-
|
|
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-B3mP_QTy.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 { t as toRepositoryFilter } from "./repositoryFilter-DKny7PDn.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,18 +280,25 @@ 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;
|
|
287
296
|
if (!isElevated(scope)) {
|
|
288
297
|
const fieldPerms = arcContext?.arc?.fields;
|
|
289
298
|
if (fieldPerms) {
|
|
290
|
-
const
|
|
299
|
+
const globalRoles = getUserRoles(req.user);
|
|
300
|
+
const orgRoles = isMember(scope) ? scope.orgRoles : [];
|
|
301
|
+
const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
|
|
291
302
|
const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
292
303
|
if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
|
|
293
304
|
sanitized = filtered;
|
|
@@ -361,6 +372,73 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
|
|
|
361
372
|
return out;
|
|
362
373
|
}
|
|
363
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
|
+
/**
|
|
364
442
|
* Resolve the repository primary key for mutation calls.
|
|
365
443
|
*
|
|
366
444
|
* When the resource declares a custom `idField` (slug, jobId, UUID), the
|
|
@@ -632,23 +710,44 @@ const log = arcLog("query");
|
|
|
632
710
|
*/
|
|
633
711
|
let notedDeniedJoins = false;
|
|
634
712
|
function noteDeniedJoins(kind) {
|
|
635
|
-
if (notedDeniedJoins || process.env.NODE_ENV
|
|
713
|
+
if (notedDeniedJoins || isProductionEnv(process.env.NODE_ENV)) return;
|
|
636
714
|
notedDeniedJoins = true;
|
|
637
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)`);
|
|
638
716
|
}
|
|
639
717
|
var QueryResolver = class {
|
|
640
718
|
queryParser;
|
|
641
719
|
maxLimit;
|
|
720
|
+
/** Set only when the HOST passed one — it outranks any parser's cap, at any time. */
|
|
721
|
+
explicitMaxLimit;
|
|
642
722
|
defaultLimit;
|
|
643
723
|
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
644
724
|
defaultSort;
|
|
725
|
+
explicitDefaultSort;
|
|
645
726
|
schemaOptions;
|
|
646
727
|
tenantField;
|
|
647
728
|
constructor(config = {}) {
|
|
648
729
|
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
649
|
-
|
|
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;
|
|
650
748
|
this.defaultLimit = config.defaultLimit ?? 20;
|
|
651
749
|
this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
|
|
750
|
+
this.explicitDefaultSort = config.defaultSort !== void 0 && config.defaultSort !== false;
|
|
652
751
|
this.schemaOptions = config.schemaOptions ?? {};
|
|
653
752
|
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
654
753
|
}
|
|
@@ -660,6 +759,42 @@ var QueryResolver = class {
|
|
|
660
759
|
*/
|
|
661
760
|
setParser(parser) {
|
|
662
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;
|
|
663
798
|
}
|
|
664
799
|
/**
|
|
665
800
|
* Resolve a request into parsed query options -- ONE parse per request.
|
|
@@ -671,7 +806,7 @@ var QueryResolver = class {
|
|
|
671
806
|
delete parsed.filters?._policyFilters;
|
|
672
807
|
const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
|
|
673
808
|
const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
674
|
-
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.
|
|
809
|
+
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
|
|
675
810
|
const rawSelect = parsed.select ?? req.query?.select;
|
|
676
811
|
const policyFilters = arcContext?._policyFilters;
|
|
677
812
|
let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
|
|
@@ -836,11 +971,38 @@ var BaseCrudController = class {
|
|
|
836
971
|
*/
|
|
837
972
|
_defaultSort;
|
|
838
973
|
_onFieldWriteDenied;
|
|
974
|
+
_onImmutableWrite;
|
|
975
|
+
/**
|
|
976
|
+
* Domain commands bound to the write slots (`ResourceWrites`). Consulted at
|
|
977
|
+
* the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
|
|
978
|
+
* injection, actor stamp, hook sandwich) runs exactly as it does without
|
|
979
|
+
* one, which is the entire point of the seam.
|
|
980
|
+
*/
|
|
981
|
+
_writes;
|
|
982
|
+
/** Transactional write envelope — see `ResourceConfig.transactional`. */
|
|
983
|
+
_transactional = false;
|
|
839
984
|
constructor(repository, options = {}) {
|
|
840
985
|
this.repository = repository;
|
|
841
986
|
this.schemaOptions = options.schemaOptions ?? {};
|
|
842
987
|
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
843
|
-
|
|
988
|
+
/**
|
|
989
|
+
* The parser's cap is consulted BEFORE the framework default.
|
|
990
|
+
*
|
|
991
|
+
* A resource that hands us a parser has already stated how large a page it
|
|
992
|
+
* can serve — a bounded catalog like a chart of accounts declares 1000 so
|
|
993
|
+
* pickers can read it whole. Defaulting straight to 100 here silently
|
|
994
|
+
* overrode that: the resource asked for 1000, `QueryResolver` received a
|
|
995
|
+
* `maxLimit` of 100 that was indistinguishable from a host deliberately
|
|
996
|
+
* choosing 100, and the list came back truncated with a `200`. be-prod
|
|
997
|
+
* served 100 of 696 accounts that way, and every account picker in the
|
|
998
|
+
* product was short by 85% with nothing anywhere to report it.
|
|
999
|
+
*
|
|
1000
|
+
* Order matters and is the whole point: an explicit `options.maxLimit` from
|
|
1001
|
+
* the host still wins over the parser, and the parser wins over this
|
|
1002
|
+
* framework floor. A general default must only ever fill a gap — never
|
|
1003
|
+
* outrank something specific that was actually stated.
|
|
1004
|
+
*/
|
|
1005
|
+
this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
|
|
844
1006
|
this.defaultLimit = options.defaultLimit ?? 20;
|
|
845
1007
|
this.resourceName = options.resourceName;
|
|
846
1008
|
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
@@ -850,6 +1012,9 @@ var BaseCrudController = class {
|
|
|
850
1012
|
if (options.presetFields) this._presetFields = options.presetFields;
|
|
851
1013
|
this._defaultSort = options.defaultSort;
|
|
852
1014
|
this._onFieldWriteDenied = options.onFieldWriteDenied;
|
|
1015
|
+
this._onImmutableWrite = options.onImmutableWrite;
|
|
1016
|
+
this._writes = options.writes;
|
|
1017
|
+
this._transactional = options.transactional === true;
|
|
853
1018
|
this.accessControl = new AccessControl({
|
|
854
1019
|
tenantField: this.tenantField,
|
|
855
1020
|
idField: this.idField,
|
|
@@ -857,7 +1022,8 @@ var BaseCrudController = class {
|
|
|
857
1022
|
});
|
|
858
1023
|
this.bodySanitizer = new BodySanitizer({
|
|
859
1024
|
schemaOptions: this.schemaOptions,
|
|
860
|
-
onFieldWriteDenied: this._onFieldWriteDenied
|
|
1025
|
+
onFieldWriteDenied: this._onFieldWriteDenied,
|
|
1026
|
+
onImmutableWrite: this._onImmutableWrite
|
|
861
1027
|
});
|
|
862
1028
|
this.queryResolver = new QueryResolver({
|
|
863
1029
|
queryParser: this.queryParser,
|
|
@@ -869,9 +1035,21 @@ var BaseCrudController = class {
|
|
|
869
1035
|
});
|
|
870
1036
|
this.list = this.list.bind(this);
|
|
871
1037
|
this.get = this.get.bind(this);
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* The write methods are bound here, which means EVERY instance carries an
|
|
1040
|
+
* own `create`/`update`/`delete` property — so "does it have an own
|
|
1041
|
+
* property" cannot distinguish arc's binding from a host's override.
|
|
1042
|
+
*
|
|
1043
|
+
* Marking what arc installed makes the difference observable. A class-field
|
|
1044
|
+
* override (`update = async (req) => …`) initialises AFTER `super()`
|
|
1045
|
+
* returns and therefore REPLACES this bound method outright — the mark goes
|
|
1046
|
+
* with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
|
|
1047
|
+
* A prototype-method override binds through here and stays marked; that
|
|
1048
|
+
* shape is caught by the prototype comparison instead.
|
|
1049
|
+
*/
|
|
1050
|
+
this.create = markArcBoundWrite(this.create.bind(this));
|
|
1051
|
+
this.update = markArcBoundWrite(this.update.bind(this));
|
|
1052
|
+
this.delete = markArcBoundWrite(this.delete.bind(this));
|
|
875
1053
|
}
|
|
876
1054
|
/**
|
|
877
1055
|
* Swap the controller's query parser. Mutates the existing `QueryResolver`
|
|
@@ -937,6 +1115,10 @@ var BaseCrudController = class {
|
|
|
937
1115
|
rebuildBodySanitizer = true;
|
|
938
1116
|
rebuildQueryResolver = true;
|
|
939
1117
|
}
|
|
1118
|
+
if (options.onImmutableWrite !== void 0) {
|
|
1119
|
+
this._onImmutableWrite = options.onImmutableWrite;
|
|
1120
|
+
rebuildBodySanitizer = true;
|
|
1121
|
+
}
|
|
940
1122
|
if (options.onFieldWriteDenied !== void 0) {
|
|
941
1123
|
this._onFieldWriteDenied = options.onFieldWriteDenied;
|
|
942
1124
|
rebuildBodySanitizer = true;
|
|
@@ -956,6 +1138,8 @@ var BaseCrudController = class {
|
|
|
956
1138
|
}
|
|
957
1139
|
if (options.cache !== void 0) this._cacheConfig = options.cache;
|
|
958
1140
|
if (options.presetFields !== void 0) this._presetFields = options.presetFields;
|
|
1141
|
+
if (options.writes !== void 0) this._writes = options.writes;
|
|
1142
|
+
if (options.transactional !== void 0) this._transactional = options.transactional === true;
|
|
959
1143
|
if (rebuildAccessControl) this.accessControl = new AccessControl({
|
|
960
1144
|
tenantField: this.tenantField,
|
|
961
1145
|
idField: this.idField,
|
|
@@ -963,7 +1147,8 @@ var BaseCrudController = class {
|
|
|
963
1147
|
});
|
|
964
1148
|
if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
|
|
965
1149
|
schemaOptions: this.schemaOptions,
|
|
966
|
-
onFieldWriteDenied: this._onFieldWriteDenied
|
|
1150
|
+
onFieldWriteDenied: this._onFieldWriteDenied,
|
|
1151
|
+
onImmutableWrite: this._onImmutableWrite
|
|
967
1152
|
});
|
|
968
1153
|
if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
|
|
969
1154
|
queryParser: this.queryParser,
|
|
@@ -1103,6 +1288,52 @@ var BaseCrudController = class {
|
|
|
1103
1288
|
return executeHookedOp(this.hookOpContext(req), args, executor);
|
|
1104
1289
|
}
|
|
1105
1290
|
/**
|
|
1291
|
+
* Run a write's PERSISTENCE step, transactionally when the resource asked.
|
|
1292
|
+
*
|
|
1293
|
+
* Plain mode hands `fn` the live repository. Transactional mode runs it
|
|
1294
|
+
* through repo-core's `retryingTransaction`: the callback receives the
|
|
1295
|
+
* TX-BOUND repository (so declared verbs and repository calls join the same
|
|
1296
|
+
* transaction), transient conflicts re-run `fn` with jittered bounded
|
|
1297
|
+
* backoff, everything else — including `VersionConflictError` — surfaces
|
|
1298
|
+
* once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
|
|
1299
|
+
* after-hooks run post-commit, which they already do by running after this.
|
|
1300
|
+
*/
|
|
1301
|
+
runWritePersistence(fn) {
|
|
1302
|
+
if (!this._transactional) return fn(this.repository);
|
|
1303
|
+
return retryingTransaction(this.repository, (txRepo, uow) => fn(txRepo, uow));
|
|
1304
|
+
}
|
|
1305
|
+
/** Assemble the `WriteContext` a `create` verb receives. */
|
|
1306
|
+
writeContext(req, repo = this.repository, uow) {
|
|
1307
|
+
return {
|
|
1308
|
+
req,
|
|
1309
|
+
repository: repo,
|
|
1310
|
+
...uow !== void 0 ? { uow } : {}
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
|
|
1315
|
+
*
|
|
1316
|
+
* `existing` is the document arc ALREADY loaded to run its permission and
|
|
1317
|
+
* tenant checks — `loadMutableTarget` throws before any verb runs when it
|
|
1318
|
+
* cannot produce one, which is why the context types it required. Handing
|
|
1319
|
+
* it over rather than letting the verb re-fetch is deliberate: a second
|
|
1320
|
+
* read can observe a different document than the one the request was
|
|
1321
|
+
* authorised against.
|
|
1322
|
+
*
|
|
1323
|
+
* `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
|
|
1324
|
+
* see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
|
|
1325
|
+
* output.
|
|
1326
|
+
*/
|
|
1327
|
+
mutationWriteContext(req, id, existing, repo = this.repository, uow) {
|
|
1328
|
+
return {
|
|
1329
|
+
req,
|
|
1330
|
+
repository: repo,
|
|
1331
|
+
id,
|
|
1332
|
+
existing,
|
|
1333
|
+
...uow !== void 0 ? { uow } : {}
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1106
1337
|
* Run `executeAfter` for the given op + data. No-op when hooks aren't
|
|
1107
1338
|
* wired or `resourceName` isn't set. Caller passes the data shape it
|
|
1108
1339
|
* wants downstream after-handlers to receive — typically the result for
|
|
@@ -1338,11 +1569,21 @@ var BaseCrudController = class {
|
|
|
1338
1569
|
const item = await this.runHookedOpUntilResult(req, {
|
|
1339
1570
|
op: "create",
|
|
1340
1571
|
input: data
|
|
1341
|
-
}, async (processed) => this.
|
|
1572
|
+
}, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.create ? this._writes.create(processed, this.writeContext(req, repo, uow)) : repo.create(processed, {
|
|
1342
1573
|
user,
|
|
1343
1574
|
context: arcContext,
|
|
1344
1575
|
...this.tenantRepoOptions(req)
|
|
1345
|
-
}));
|
|
1576
|
+
})));
|
|
1577
|
+
/**
|
|
1578
|
+
* A create COMMAND must return the created document — enforced, because
|
|
1579
|
+
* the alternative is quietly worse than any failure: a `201` carrying
|
|
1580
|
+
* `undefined` data, after-hooks (audit, events, cache invalidation) fed a
|
|
1581
|
+
* non-document, and a client that believes the write happened with no way
|
|
1582
|
+
* to address what it created. TypeScript types the contract, but arc is a
|
|
1583
|
+
* JavaScript runtime framework and a JS command returning nothing is one
|
|
1584
|
+
* missing `return` away.
|
|
1585
|
+
*/
|
|
1586
|
+
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" });
|
|
1346
1587
|
await this.runAfterHook(req, "create", item);
|
|
1347
1588
|
return {
|
|
1348
1589
|
data: item,
|
|
@@ -1365,15 +1606,27 @@ var BaseCrudController = class {
|
|
|
1365
1606
|
op: "update",
|
|
1366
1607
|
input: data,
|
|
1367
1608
|
meta: hookMeta
|
|
1368
|
-
}, async (processed) => this.
|
|
1609
|
+
}, 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, {
|
|
1369
1610
|
user,
|
|
1370
1611
|
context: arcContext,
|
|
1371
1612
|
...this.tenantRepoOptions(req)
|
|
1372
|
-
}));
|
|
1373
|
-
|
|
1374
|
-
|
|
1613
|
+
})));
|
|
1614
|
+
/**
|
|
1615
|
+
* A declared verb signals "not found" by THROWING, so reaching this line
|
|
1616
|
+
* means the command succeeded. Translating its `void` return into a 404
|
|
1617
|
+
* would report failure for a write that happened — the repository's
|
|
1618
|
+
* `null`-means-miss contract is the repository's, not a domain command's.
|
|
1619
|
+
* Re-read so the response still carries the document.
|
|
1620
|
+
*/
|
|
1621
|
+
const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
|
|
1622
|
+
user,
|
|
1623
|
+
context: arcContext,
|
|
1624
|
+
...this.tenantRepoOptions(req)
|
|
1625
|
+
}) : item;
|
|
1626
|
+
if (!resolved) this.throwNotFound("NOT_FOUND");
|
|
1627
|
+
await this.runAfterHook(req, "update", resolved, hookMeta);
|
|
1375
1628
|
return {
|
|
1376
|
-
data:
|
|
1629
|
+
data: resolved,
|
|
1377
1630
|
status: 200,
|
|
1378
1631
|
meta: { message: "Updated successfully" }
|
|
1379
1632
|
};
|
|
@@ -1389,15 +1642,15 @@ var BaseCrudController = class {
|
|
|
1389
1642
|
input: existing,
|
|
1390
1643
|
meta: hookMeta,
|
|
1391
1644
|
pipeProcessedData: false
|
|
1392
|
-
}, async () => this.
|
|
1645
|
+
}, async () => this.runWritePersistence((repo, uow) => this._writes?.delete ? this._writes.delete(repoId, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.delete(repoId, {
|
|
1393
1646
|
user,
|
|
1394
1647
|
context: arcContext,
|
|
1395
1648
|
...this.tenantRepoOptions(req),
|
|
1396
1649
|
...deleteMode ? { mode: deleteMode } : {}
|
|
1397
|
-
}));
|
|
1398
|
-
if (!result) this.throwNotFound("NOT_FOUND");
|
|
1650
|
+
})));
|
|
1651
|
+
if (!result && !this._writes?.delete) this.throwNotFound("NOT_FOUND");
|
|
1399
1652
|
await this.runAfterHook(req, "delete", existing, hookMeta);
|
|
1400
|
-
const deleteResult = result;
|
|
1653
|
+
const deleteResult = result ?? {};
|
|
1401
1654
|
return {
|
|
1402
1655
|
data: {
|
|
1403
1656
|
message: deleteResult.message || "Deleted successfully",
|
|
@@ -1408,6 +1661,15 @@ var BaseCrudController = class {
|
|
|
1408
1661
|
};
|
|
1409
1662
|
}
|
|
1410
1663
|
};
|
|
1664
|
+
/**
|
|
1665
|
+
* This class's write methods dispatch declared write verbs (`_writes`), so
|
|
1666
|
+
* `defineResource` may accept a `writes` declaration for controllers built on
|
|
1667
|
+
* it. One mark on the prototype — inherited by every subclass and mixin
|
|
1668
|
+
* composition, zero per-instance cost. A duck-typed `configure()` is NOT this
|
|
1669
|
+
* proof: it shows an options channel exists, not that anything dispatches
|
|
1670
|
+
* `writes` out of it.
|
|
1671
|
+
*/
|
|
1672
|
+
markWriteVerbCapable(BaseCrudController.prototype);
|
|
1411
1673
|
//#endregion
|
|
1412
1674
|
//#region src/core/mixins/bulk.ts
|
|
1413
1675
|
/**
|
|
@@ -1638,7 +1900,8 @@ function SlugMixin(Base) {
|
|
|
1638
1900
|
} else throw createError(501, "Slug lookup not implemented — repository needs getBySlug() or getOne()");
|
|
1639
1901
|
if (!this.accessControl.validateItemAccess(item, req)) {
|
|
1640
1902
|
const code = item ? "POLICY_FILTERED" : "NOT_FOUND";
|
|
1641
|
-
const
|
|
1903
|
+
const resource = this.resourceName ?? "Resource";
|
|
1904
|
+
const err = new NotFoundError(resource);
|
|
1642
1905
|
err.details = {
|
|
1643
1906
|
...err.details ?? {},
|
|
1644
1907
|
code
|
|
@@ -1749,4 +2012,4 @@ function TreeMixin(Base) {
|
|
|
1749
2012
|
*/
|
|
1750
2013
|
var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
|
|
1751
2014
|
//#endregion
|
|
1752
|
-
export { BulkMixin as a, collectReadBlockedFields as c,
|
|
2015
|
+
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
|