@open-mercato/enterprise 0.6.6-develop.6474.1.2c411ad42b → 0.6.6-develop.6481.1.c1f82e3ed6

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.
@@ -146,7 +146,8 @@ const openApi = {
146
146
  errors: [
147
147
  { status: 400, description: "Invalid payload", schema: recordLockErrorSchema },
148
148
  { status: 401, description: "Unauthorized", schema: recordLockErrorSchema },
149
- { status: 423, description: "Record locked by another user", schema: recordLockErrorSchema }
149
+ { status: 423, description: "Record locked by another user", schema: recordLockErrorSchema },
150
+ { status: 429, description: "Per-user active lock quota exceeded", schema: recordLockErrorSchema }
150
151
  ]
151
152
  }
152
153
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/record_locks/api/acquire/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n recordLockAcquireResponseSchema,\n recordLockAcquireSchema,\n recordLockErrorSchema,\n} from '../../data/validators'\nimport { resolveRecordLocksApiContext, resolveRequestIp } from '../utils'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\n\ntype LockPayload = {\n lockedByUserId?: string\n lockedByIp?: string | null\n lockedByName?: string | null\n lockedByEmail?: string | null\n participants?: Array<{\n userId: string\n lockedByIp?: string | null\n lockedByName?: string | null\n lockedByEmail?: string | null\n lockedAt: string\n lastHeartbeatAt: string\n expiresAt: string\n }>\n activeParticipantCount?: number\n}\n\nfunction maskEmail(email: string | null | undefined): string | null {\n if (typeof email !== 'string') return null\n const normalized = email.trim().toLowerCase()\n const atIndex = normalized.indexOf('@')\n if (atIndex <= 0 || atIndex === normalized.length - 1) return null\n const local = normalized.slice(0, atIndex)\n const domain = normalized.slice(atIndex + 1)\n const dotIndex = domain.indexOf('.')\n if (dotIndex <= 0 || dotIndex === domain.length - 1) return null\n const domainName = domain.slice(0, dotIndex)\n const domainSuffix = domain.slice(dotIndex)\n const localPrefix = local.slice(0, Math.min(2, local.length))\n const domainPrefix = domainName.slice(0, Math.min(4, domainName.length))\n return `${localPrefix}**@${domainPrefix}**${domainSuffix}`\n}\n\nasync function redactPersonalData(\n em: EntityManager,\n lock: LockPayload | null | undefined,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<LockPayload | null> {\n if (!lock) return null\n const userIds = new Set<string>()\n if (typeof lock.lockedByUserId === 'string' && lock.lockedByUserId.trim().length > 0) {\n userIds.add(lock.lockedByUserId)\n }\n for (const participant of lock.participants ?? []) {\n if (typeof participant.userId === 'string' && participant.userId.trim().length > 0) {\n userIds.add(participant.userId)\n }\n }\n\n const users = userIds.size\n ? await em.find(User, {\n id: { $in: Array.from(userIds) },\n deletedAt: null,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n : []\n const userById = new Map(users.map((user) => [user.id, user]))\n\n const participants = (lock.participants ?? []).map((participant) => {\n const { lockedByIp, lockedByName, lockedByEmail, ...rest } = participant\n const maskedEmail = maskEmail(userById.get(participant.userId)?.email ?? null)\n return {\n ...rest,\n ...(maskedEmail ? { lockedByEmail: maskedEmail } : {}),\n }\n })\n\n const { lockedByIp, lockedByName, lockedByEmail, lockedByUserId, ...rest } = lock\n const maskedOwnerEmail = maskEmail(\n lockedByUserId ? (userById.get(lockedByUserId)?.email ?? null) : null,\n )\n return {\n ...rest,\n lockedByIp: null,\n lockedByName: null,\n lockedByEmail: maskedOwnerEmail,\n participants,\n activeParticipantCount: lock.activeParticipantCount ?? participants.length,\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['record_locks.view'] },\n}\n\nasync function resolveCanForceRelease(\n ctx: Exclude<Awaited<ReturnType<typeof resolveRecordLocksApiContext>>, Response>,\n): Promise<boolean> {\n try {\n const rbacService = ctx.container.resolve<RbacService>('rbacService')\n return await rbacService.userHasAllFeatures(\n ctx.auth.sub,\n ['record_locks.force_release'],\n {\n tenantId: ctx.auth.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n )\n } catch {\n return false\n }\n}\n\nexport async function POST(req: Request) {\n const ctxOrResponse = await resolveRecordLocksApiContext(req)\n if (ctxOrResponse instanceof Response) return ctxOrResponse\n\n const body = await req.json().catch(() => ({}))\n const parsed = recordLockAcquireSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid acquire payload', details: parsed.error.issues }, { status: 400 })\n }\n\n const result = await ctxOrResponse.recordLockService.acquire({\n resourceKind: parsed.data.resourceKind,\n resourceId: parsed.data.resourceId,\n tenantId: ctxOrResponse.auth.tenantId,\n organizationId: ctxOrResponse.organizationId,\n userId: ctxOrResponse.auth.sub,\n lockedByIp: resolveRequestIp(req),\n })\n\n const em = ctxOrResponse.container.resolve<EntityManager>('em').fork()\n const canForceRelease = result.allowForceUnlock\n ? await resolveCanForceRelease(ctxOrResponse)\n : false\n const allowForceUnlock = result.allowForceUnlock && canForceRelease\n\n if (!result.ok) {\n const lock = await redactPersonalData(em, result.lock as LockPayload | null, {\n tenantId: ctxOrResponse.auth.tenantId,\n organizationId: ctxOrResponse.organizationId ?? null,\n })\n return NextResponse.json(\n {\n error: result.error,\n code: result.code,\n allowForceUnlock,\n currentUserId: ctxOrResponse.auth.sub,\n lock,\n },\n { status: result.status },\n )\n }\n\n const lock = await redactPersonalData(em, result.lock as LockPayload | null, {\n tenantId: ctxOrResponse.auth.tenantId,\n organizationId: ctxOrResponse.organizationId ?? null,\n })\n return NextResponse.json({\n ok: true,\n enabled: result.enabled,\n resourceEnabled: result.resourceEnabled,\n strategy: result.strategy,\n heartbeatSeconds: result.heartbeatSeconds,\n acquired: result.acquired,\n latestActionLogId: result.latestActionLogId,\n allowForceUnlock,\n currentUserId: ctxOrResponse.auth.sub,\n lock,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Record Locks',\n summary: 'Acquire record lock',\n methods: {\n POST: {\n summary: 'Acquire lock for editing',\n requestBody: {\n contentType: 'application/json',\n schema: recordLockAcquireSchema,\n },\n responses: [\n { status: 200, description: 'Lock acquisition result', schema: recordLockAcquireResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload', schema: recordLockErrorSchema },\n { status: 401, description: 'Unauthorized', schema: recordLockErrorSchema },\n { status: 423, description: 'Record locked by another user', schema: recordLockErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAE7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,8BAA8B,wBAAwB;AAE/D,SAAS,YAAY;AAoBrB,SAAS,UAAU,OAAiD;AAClE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,aAAa,MAAM,KAAK,EAAE,YAAY;AAC5C,QAAM,UAAU,WAAW,QAAQ,GAAG;AACtC,MAAI,WAAW,KAAK,YAAY,WAAW,SAAS,EAAG,QAAO;AAC9D,QAAM,QAAQ,WAAW,MAAM,GAAG,OAAO;AACzC,QAAM,SAAS,WAAW,MAAM,UAAU,CAAC;AAC3C,QAAM,WAAW,OAAO,QAAQ,GAAG;AACnC,MAAI,YAAY,KAAK,aAAa,OAAO,SAAS,EAAG,QAAO;AAC5D,QAAM,aAAa,OAAO,MAAM,GAAG,QAAQ;AAC3C,QAAM,eAAe,OAAO,MAAM,QAAQ;AAC1C,QAAM,cAAc,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,MAAM,MAAM,CAAC;AAC5D,QAAM,eAAe,WAAW,MAAM,GAAG,KAAK,IAAI,GAAG,WAAW,MAAM,CAAC;AACvE,SAAO,GAAG,WAAW,MAAM,YAAY,KAAK,YAAY;AAC1D;AAEA,eAAe,mBACb,IACA,MACA,OAC6B;AAC7B,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,oBAAI,IAAY;AAChC,MAAI,OAAO,KAAK,mBAAmB,YAAY,KAAK,eAAe,KAAK,EAAE,SAAS,GAAG;AACpF,YAAQ,IAAI,KAAK,cAAc;AAAA,EACjC;AACA,aAAW,eAAe,KAAK,gBAAgB,CAAC,GAAG;AACjD,QAAI,OAAO,YAAY,WAAW,YAAY,YAAY,OAAO,KAAK,EAAE,SAAS,GAAG;AAClF,cAAQ,IAAI,YAAY,MAAM;AAAA,IAChC;AAAA,EACF;AAEA,QAAM,QAAQ,QAAQ,OAClB,MAAM,GAAG,KAAK,MAAM;AAAA,IACpB,IAAI,EAAE,KAAK,MAAM,KAAK,OAAO,EAAE;AAAA,IAC/B,WAAW;AAAA,IACX,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,EACxB,CAAC,IACC,CAAC;AACL,QAAM,WAAW,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;AAE7D,QAAM,gBAAgB,KAAK,gBAAgB,CAAC,GAAG,IAAI,CAAC,gBAAgB;AAClE,UAAM,EAAE,YAAAA,aAAY,cAAAC,eAAc,eAAAC,gBAAe,GAAGC,MAAK,IAAI;AAC7D,UAAM,cAAc,UAAU,SAAS,IAAI,YAAY,MAAM,GAAG,SAAS,IAAI;AAC7E,WAAO;AAAA,MACL,GAAGA;AAAA,MACH,GAAI,cAAc,EAAE,eAAe,YAAY,IAAI,CAAC;AAAA,IACtD;AAAA,EACF,CAAC;AAED,QAAM,EAAE,YAAY,cAAc,eAAe,gBAAgB,GAAG,KAAK,IAAI;AAC7E,QAAM,mBAAmB;AAAA,IACvB,iBAAkB,SAAS,IAAI,cAAc,GAAG,SAAS,OAAQ;AAAA,EACnE;AACA,SAAO;AAAA,IACL,GAAG;AAAA,IACH,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,eAAe;AAAA,IACf;AAAA,IACA,wBAAwB,KAAK,0BAA0B,aAAa;AAAA,EACtE;AACF;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,eAAe,uBACb,KACkB;AAClB,MAAI;AACF,UAAM,cAAc,IAAI,UAAU,QAAqB,aAAa;AACpE,WAAO,MAAM,YAAY;AAAA,MACvB,IAAI,KAAK;AAAA,MACT,CAAC,4BAA4B;AAAA,MAC7B;AAAA,QACE,UAAU,IAAI,KAAK;AAAA,QACnB,gBAAgB,IAAI,kBAAkB;AAAA,MACxC;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,gBAAgB,MAAM,6BAA6B,GAAG;AAC5D,MAAI,yBAAyB,SAAU,QAAO;AAE9C,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC9C,QAAM,SAAS,wBAAwB,UAAU,IAAI;AACrD,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,2BAA2B,SAAS,OAAO,MAAM,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9G;AAEA,QAAM,SAAS,MAAM,cAAc,kBAAkB,QAAQ;AAAA,IAC3D,cAAc,OAAO,KAAK;AAAA,IAC1B,YAAY,OAAO,KAAK;AAAA,IACxB,UAAU,cAAc,KAAK;AAAA,IAC7B,gBAAgB,cAAc;AAAA,IAC9B,QAAQ,cAAc,KAAK;AAAA,IAC3B,YAAY,iBAAiB,GAAG;AAAA,EAClC,CAAC;AAED,QAAM,KAAK,cAAc,UAAU,QAAuB,IAAI,EAAE,KAAK;AACrE,QAAM,kBAAkB,OAAO,mBAC3B,MAAM,uBAAuB,aAAa,IAC1C;AACJ,QAAM,mBAAmB,OAAO,oBAAoB;AAEpD,MAAI,CAAC,OAAO,IAAI;AACd,UAAMC,QAAO,MAAM,mBAAmB,IAAI,OAAO,MAA4B;AAAA,MAC3E,UAAU,cAAc,KAAK;AAAA,MAC7B,gBAAgB,cAAc,kBAAkB;AAAA,IAClD,CAAC;AACD,WAAO,aAAa;AAAA,MAClB;AAAA,QACE,OAAO,OAAO;AAAA,QACd,MAAM,OAAO;AAAA,QACb;AAAA,QACA,eAAe,cAAc,KAAK;AAAA,QAClC,MAAAA;AAAA,MACF;AAAA,MACA,EAAE,QAAQ,OAAO,OAAO;AAAA,IAC1B;AAAA,EACF;AAEA,QAAM,OAAO,MAAM,mBAAmB,IAAI,OAAO,MAA4B;AAAA,IAC3E,UAAU,cAAc,KAAK;AAAA,IAC7B,gBAAgB,cAAc,kBAAkB;AAAA,EAClD,CAAC;AACD,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ,SAAS,OAAO;AAAA,IAChB,iBAAiB,OAAO;AAAA,IACxB,UAAU,OAAO;AAAA,IACjB,kBAAkB,OAAO;AAAA,IACzB,UAAU,OAAO;AAAA,IACjB,mBAAmB,OAAO;AAAA,IAC1B;AAAA,IACA,eAAe,cAAc,KAAK;AAAA,IAClC;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,2BAA2B,QAAQ,gCAAgC;AAAA,MACjG;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,sBAAsB;AAAA,QAC7E,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,sBAAsB;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,iCAAiC,QAAQ,sBAAsB;AAAA,MAC7F;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n recordLockAcquireResponseSchema,\n recordLockAcquireSchema,\n recordLockErrorSchema,\n} from '../../data/validators'\nimport { resolveRecordLocksApiContext, resolveRequestIp } from '../utils'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\n\ntype LockPayload = {\n lockedByUserId?: string\n lockedByIp?: string | null\n lockedByName?: string | null\n lockedByEmail?: string | null\n participants?: Array<{\n userId: string\n lockedByIp?: string | null\n lockedByName?: string | null\n lockedByEmail?: string | null\n lockedAt: string\n lastHeartbeatAt: string\n expiresAt: string\n }>\n activeParticipantCount?: number\n}\n\nfunction maskEmail(email: string | null | undefined): string | null {\n if (typeof email !== 'string') return null\n const normalized = email.trim().toLowerCase()\n const atIndex = normalized.indexOf('@')\n if (atIndex <= 0 || atIndex === normalized.length - 1) return null\n const local = normalized.slice(0, atIndex)\n const domain = normalized.slice(atIndex + 1)\n const dotIndex = domain.indexOf('.')\n if (dotIndex <= 0 || dotIndex === domain.length - 1) return null\n const domainName = domain.slice(0, dotIndex)\n const domainSuffix = domain.slice(dotIndex)\n const localPrefix = local.slice(0, Math.min(2, local.length))\n const domainPrefix = domainName.slice(0, Math.min(4, domainName.length))\n return `${localPrefix}**@${domainPrefix}**${domainSuffix}`\n}\n\nasync function redactPersonalData(\n em: EntityManager,\n lock: LockPayload | null | undefined,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<LockPayload | null> {\n if (!lock) return null\n const userIds = new Set<string>()\n if (typeof lock.lockedByUserId === 'string' && lock.lockedByUserId.trim().length > 0) {\n userIds.add(lock.lockedByUserId)\n }\n for (const participant of lock.participants ?? []) {\n if (typeof participant.userId === 'string' && participant.userId.trim().length > 0) {\n userIds.add(participant.userId)\n }\n }\n\n const users = userIds.size\n ? await em.find(User, {\n id: { $in: Array.from(userIds) },\n deletedAt: null,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n : []\n const userById = new Map(users.map((user) => [user.id, user]))\n\n const participants = (lock.participants ?? []).map((participant) => {\n const { lockedByIp, lockedByName, lockedByEmail, ...rest } = participant\n const maskedEmail = maskEmail(userById.get(participant.userId)?.email ?? null)\n return {\n ...rest,\n ...(maskedEmail ? { lockedByEmail: maskedEmail } : {}),\n }\n })\n\n const { lockedByIp, lockedByName, lockedByEmail, lockedByUserId, ...rest } = lock\n const maskedOwnerEmail = maskEmail(\n lockedByUserId ? (userById.get(lockedByUserId)?.email ?? null) : null,\n )\n return {\n ...rest,\n lockedByIp: null,\n lockedByName: null,\n lockedByEmail: maskedOwnerEmail,\n participants,\n activeParticipantCount: lock.activeParticipantCount ?? participants.length,\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['record_locks.view'] },\n}\n\nasync function resolveCanForceRelease(\n ctx: Exclude<Awaited<ReturnType<typeof resolveRecordLocksApiContext>>, Response>,\n): Promise<boolean> {\n try {\n const rbacService = ctx.container.resolve<RbacService>('rbacService')\n return await rbacService.userHasAllFeatures(\n ctx.auth.sub,\n ['record_locks.force_release'],\n {\n tenantId: ctx.auth.tenantId,\n organizationId: ctx.organizationId ?? null,\n },\n )\n } catch {\n return false\n }\n}\n\nexport async function POST(req: Request) {\n const ctxOrResponse = await resolveRecordLocksApiContext(req)\n if (ctxOrResponse instanceof Response) return ctxOrResponse\n\n const body = await req.json().catch(() => ({}))\n const parsed = recordLockAcquireSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid acquire payload', details: parsed.error.issues }, { status: 400 })\n }\n\n const result = await ctxOrResponse.recordLockService.acquire({\n resourceKind: parsed.data.resourceKind,\n resourceId: parsed.data.resourceId,\n tenantId: ctxOrResponse.auth.tenantId,\n organizationId: ctxOrResponse.organizationId,\n userId: ctxOrResponse.auth.sub,\n lockedByIp: resolveRequestIp(req),\n })\n\n const em = ctxOrResponse.container.resolve<EntityManager>('em').fork()\n const canForceRelease = result.allowForceUnlock\n ? await resolveCanForceRelease(ctxOrResponse)\n : false\n const allowForceUnlock = result.allowForceUnlock && canForceRelease\n\n if (!result.ok) {\n const lock = await redactPersonalData(em, result.lock as LockPayload | null, {\n tenantId: ctxOrResponse.auth.tenantId,\n organizationId: ctxOrResponse.organizationId ?? null,\n })\n return NextResponse.json(\n {\n error: result.error,\n code: result.code,\n allowForceUnlock,\n currentUserId: ctxOrResponse.auth.sub,\n lock,\n },\n { status: result.status },\n )\n }\n\n const lock = await redactPersonalData(em, result.lock as LockPayload | null, {\n tenantId: ctxOrResponse.auth.tenantId,\n organizationId: ctxOrResponse.organizationId ?? null,\n })\n return NextResponse.json({\n ok: true,\n enabled: result.enabled,\n resourceEnabled: result.resourceEnabled,\n strategy: result.strategy,\n heartbeatSeconds: result.heartbeatSeconds,\n acquired: result.acquired,\n latestActionLogId: result.latestActionLogId,\n allowForceUnlock,\n currentUserId: ctxOrResponse.auth.sub,\n lock,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Record Locks',\n summary: 'Acquire record lock',\n methods: {\n POST: {\n summary: 'Acquire lock for editing',\n requestBody: {\n contentType: 'application/json',\n schema: recordLockAcquireSchema,\n },\n responses: [\n { status: 200, description: 'Lock acquisition result', schema: recordLockAcquireResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload', schema: recordLockErrorSchema },\n { status: 401, description: 'Unauthorized', schema: recordLockErrorSchema },\n { status: 423, description: 'Record locked by another user', schema: recordLockErrorSchema },\n { status: 429, description: 'Per-user active lock quota exceeded', schema: recordLockErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAE7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,8BAA8B,wBAAwB;AAE/D,SAAS,YAAY;AAoBrB,SAAS,UAAU,OAAiD;AAClE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,aAAa,MAAM,KAAK,EAAE,YAAY;AAC5C,QAAM,UAAU,WAAW,QAAQ,GAAG;AACtC,MAAI,WAAW,KAAK,YAAY,WAAW,SAAS,EAAG,QAAO;AAC9D,QAAM,QAAQ,WAAW,MAAM,GAAG,OAAO;AACzC,QAAM,SAAS,WAAW,MAAM,UAAU,CAAC;AAC3C,QAAM,WAAW,OAAO,QAAQ,GAAG;AACnC,MAAI,YAAY,KAAK,aAAa,OAAO,SAAS,EAAG,QAAO;AAC5D,QAAM,aAAa,OAAO,MAAM,GAAG,QAAQ;AAC3C,QAAM,eAAe,OAAO,MAAM,QAAQ;AAC1C,QAAM,cAAc,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,MAAM,MAAM,CAAC;AAC5D,QAAM,eAAe,WAAW,MAAM,GAAG,KAAK,IAAI,GAAG,WAAW,MAAM,CAAC;AACvE,SAAO,GAAG,WAAW,MAAM,YAAY,KAAK,YAAY;AAC1D;AAEA,eAAe,mBACb,IACA,MACA,OAC6B;AAC7B,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,oBAAI,IAAY;AAChC,MAAI,OAAO,KAAK,mBAAmB,YAAY,KAAK,eAAe,KAAK,EAAE,SAAS,GAAG;AACpF,YAAQ,IAAI,KAAK,cAAc;AAAA,EACjC;AACA,aAAW,eAAe,KAAK,gBAAgB,CAAC,GAAG;AACjD,QAAI,OAAO,YAAY,WAAW,YAAY,YAAY,OAAO,KAAK,EAAE,SAAS,GAAG;AAClF,cAAQ,IAAI,YAAY,MAAM;AAAA,IAChC;AAAA,EACF;AAEA,QAAM,QAAQ,QAAQ,OAClB,MAAM,GAAG,KAAK,MAAM;AAAA,IACpB,IAAI,EAAE,KAAK,MAAM,KAAK,OAAO,EAAE;AAAA,IAC/B,WAAW;AAAA,IACX,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,EACxB,CAAC,IACC,CAAC;AACL,QAAM,WAAW,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;AAE7D,QAAM,gBAAgB,KAAK,gBAAgB,CAAC,GAAG,IAAI,CAAC,gBAAgB;AAClE,UAAM,EAAE,YAAAA,aAAY,cAAAC,eAAc,eAAAC,gBAAe,GAAGC,MAAK,IAAI;AAC7D,UAAM,cAAc,UAAU,SAAS,IAAI,YAAY,MAAM,GAAG,SAAS,IAAI;AAC7E,WAAO;AAAA,MACL,GAAGA;AAAA,MACH,GAAI,cAAc,EAAE,eAAe,YAAY,IAAI,CAAC;AAAA,IACtD;AAAA,EACF,CAAC;AAED,QAAM,EAAE,YAAY,cAAc,eAAe,gBAAgB,GAAG,KAAK,IAAI;AAC7E,QAAM,mBAAmB;AAAA,IACvB,iBAAkB,SAAS,IAAI,cAAc,GAAG,SAAS,OAAQ;AAAA,EACnE;AACA,SAAO;AAAA,IACL,GAAG;AAAA,IACH,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,eAAe;AAAA,IACf;AAAA,IACA,wBAAwB,KAAK,0BAA0B,aAAa;AAAA,EACtE;AACF;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,eAAe,uBACb,KACkB;AAClB,MAAI;AACF,UAAM,cAAc,IAAI,UAAU,QAAqB,aAAa;AACpE,WAAO,MAAM,YAAY;AAAA,MACvB,IAAI,KAAK;AAAA,MACT,CAAC,4BAA4B;AAAA,MAC7B;AAAA,QACE,UAAU,IAAI,KAAK;AAAA,QACnB,gBAAgB,IAAI,kBAAkB;AAAA,MACxC;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,gBAAgB,MAAM,6BAA6B,GAAG;AAC5D,MAAI,yBAAyB,SAAU,QAAO;AAE9C,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC9C,QAAM,SAAS,wBAAwB,UAAU,IAAI;AACrD,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,2BAA2B,SAAS,OAAO,MAAM,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9G;AAEA,QAAM,SAAS,MAAM,cAAc,kBAAkB,QAAQ;AAAA,IAC3D,cAAc,OAAO,KAAK;AAAA,IAC1B,YAAY,OAAO,KAAK;AAAA,IACxB,UAAU,cAAc,KAAK;AAAA,IAC7B,gBAAgB,cAAc;AAAA,IAC9B,QAAQ,cAAc,KAAK;AAAA,IAC3B,YAAY,iBAAiB,GAAG;AAAA,EAClC,CAAC;AAED,QAAM,KAAK,cAAc,UAAU,QAAuB,IAAI,EAAE,KAAK;AACrE,QAAM,kBAAkB,OAAO,mBAC3B,MAAM,uBAAuB,aAAa,IAC1C;AACJ,QAAM,mBAAmB,OAAO,oBAAoB;AAEpD,MAAI,CAAC,OAAO,IAAI;AACd,UAAMC,QAAO,MAAM,mBAAmB,IAAI,OAAO,MAA4B;AAAA,MAC3E,UAAU,cAAc,KAAK;AAAA,MAC7B,gBAAgB,cAAc,kBAAkB;AAAA,IAClD,CAAC;AACD,WAAO,aAAa;AAAA,MAClB;AAAA,QACE,OAAO,OAAO;AAAA,QACd,MAAM,OAAO;AAAA,QACb;AAAA,QACA,eAAe,cAAc,KAAK;AAAA,QAClC,MAAAA;AAAA,MACF;AAAA,MACA,EAAE,QAAQ,OAAO,OAAO;AAAA,IAC1B;AAAA,EACF;AAEA,QAAM,OAAO,MAAM,mBAAmB,IAAI,OAAO,MAA4B;AAAA,IAC3E,UAAU,cAAc,KAAK;AAAA,IAC7B,gBAAgB,cAAc,kBAAkB;AAAA,EAClD,CAAC;AACD,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ,SAAS,OAAO;AAAA,IAChB,iBAAiB,OAAO;AAAA,IACxB,UAAU,OAAO;AAAA,IACjB,kBAAkB,OAAO;AAAA,IACzB,UAAU,OAAO;AAAA,IACjB,mBAAmB,OAAO;AAAA,IAC1B;AAAA,IACA,eAAe,cAAc,KAAK;AAAA,IAClC;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,2BAA2B,QAAQ,gCAAgC;AAAA,MACjG;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,sBAAsB;AAAA,QAC7E,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,sBAAsB;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,iCAAiC,QAAQ,sBAAsB;AAAA,QAC3F,EAAE,QAAQ,KAAK,aAAa,uCAAuC,QAAQ,sBAAsB;AAAA,MACnG;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": ["lockedByIp", "lockedByName", "lockedByEmail", "rest", "lock"]
7
7
  }
@@ -5,6 +5,7 @@ const recordLockSettingsSchema = z.object({
5
5
  strategy: recordLockStrategySchema.default("optimistic"),
6
6
  timeoutSeconds: z.number().int().min(30).max(3600).default(300),
7
7
  heartbeatSeconds: z.number().int().min(5).max(300).default(30),
8
+ maxActiveLocksPerUser: z.number().int().min(1).max(500).default(50),
8
9
  enabledResources: z.array(z.string().trim().min(1)).default([]),
9
10
  allowForceUnlock: z.boolean().default(true),
10
11
  allowIncomingOverride: z.boolean().default(true),
@@ -15,6 +16,7 @@ const DEFAULT_RECORD_LOCK_SETTINGS = {
15
16
  strategy: "optimistic",
16
17
  timeoutSeconds: 300,
17
18
  heartbeatSeconds: 30,
19
+ maxActiveLocksPerUser: 50,
18
20
  enabledResources: ["*"],
19
21
  allowForceUnlock: true,
20
22
  allowIncomingOverride: true,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/record_locks/lib/config.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\n\nexport const recordLockStrategySchema = z.enum(['optimistic', 'pessimistic'])\n\nexport const recordLockSettingsSchema = z.object({\n enabled: z.boolean().default(true),\n strategy: recordLockStrategySchema.default('optimistic'),\n timeoutSeconds: z.number().int().min(30).max(3600).default(300),\n heartbeatSeconds: z.number().int().min(5).max(300).default(30),\n enabledResources: z.array(z.string().trim().min(1)).default([]),\n allowForceUnlock: z.boolean().default(true),\n allowIncomingOverride: z.boolean().default(true),\n notifyOnConflict: z.boolean().default(true),\n})\n\nexport type RecordLockStrategy = z.infer<typeof recordLockStrategySchema>\nexport type RecordLockSettings = z.infer<typeof recordLockSettingsSchema>\n\nexport const DEFAULT_RECORD_LOCK_SETTINGS: RecordLockSettings = {\n enabled: true,\n strategy: 'optimistic',\n timeoutSeconds: 300,\n heartbeatSeconds: 30,\n enabledResources: ['*'],\n allowForceUnlock: true,\n allowIncomingOverride: true,\n notifyOnConflict: true,\n}\n\nexport const RECORD_LOCKS_MODULE_ID = 'record_locks'\nexport const RECORD_LOCKS_SETTINGS_NAME = 'settings'\n\nexport function normalizeRecordLockSettings(input: unknown): RecordLockSettings {\n const parsed = recordLockSettingsSchema.parse(input ?? {})\n const seen = new Set<string>()\n const enabledResources = parsed.enabledResources\n .map((resource) => resource.trim())\n .filter((resource) => {\n if (!resource) return false\n if (seen.has(resource)) return false\n seen.add(resource)\n return true\n })\n\n return {\n ...parsed,\n enabledResources,\n }\n}\n\nexport function isRecordLockingEnabledForResource(\n settings: RecordLockSettings,\n resourceKind: string | null | undefined,\n): boolean {\n if (!settings.enabled) return false\n if (!resourceKind || resourceKind.trim().length === 0) return false\n const normalizedResourceKind = resourceKind.trim()\n const enabledResources = settings.enabledResources.map((item) => item.trim()).filter((item) => item.length > 0)\n if (!enabledResources.length) return true\n if (enabledResources.includes('*')) return true\n return enabledResources.some((entry) => {\n if (entry === normalizedResourceKind) return true\n if (entry.endsWith('.*')) {\n const prefix = entry.slice(0, -1)\n return normalizedResourceKind.startsWith(prefix)\n }\n return false\n })\n}\n"],
5
- "mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,2BAA2B,EAAE,KAAK,CAAC,cAAc,aAAa,CAAC;AAErE,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,SAAS,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA,EACjC,UAAU,yBAAyB,QAAQ,YAAY;AAAA,EACvD,gBAAgB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,IAAI,IAAI,EAAE,QAAQ,GAAG;AAAA,EAC9D,kBAAkB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EAC7D,kBAAkB,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AAAA,EAC9D,kBAAkB,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA,EAC1C,uBAAuB,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA,EAC/C,kBAAkB,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAC5C,CAAC;AAKM,MAAM,+BAAmD;AAAA,EAC9D,SAAS;AAAA,EACT,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB,kBAAkB;AAAA,EAClB,kBAAkB,CAAC,GAAG;AAAA,EACtB,kBAAkB;AAAA,EAClB,uBAAuB;AAAA,EACvB,kBAAkB;AACpB;AAEO,MAAM,yBAAyB;AAC/B,MAAM,6BAA6B;AAEnC,SAAS,4BAA4B,OAAoC;AAC9E,QAAM,SAAS,yBAAyB,MAAM,SAAS,CAAC,CAAC;AACzD,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,mBAAmB,OAAO,iBAC7B,IAAI,CAAC,aAAa,SAAS,KAAK,CAAC,EACjC,OAAO,CAAC,aAAa;AACpB,QAAI,CAAC,SAAU,QAAO;AACtB,QAAI,KAAK,IAAI,QAAQ,EAAG,QAAO;AAC/B,SAAK,IAAI,QAAQ;AACjB,WAAO;AAAA,EACT,CAAC;AAEH,SAAO;AAAA,IACL,GAAG;AAAA,IACH;AAAA,EACF;AACF;AAEO,SAAS,kCACd,UACA,cACS;AACT,MAAI,CAAC,SAAS,QAAS,QAAO;AAC9B,MAAI,CAAC,gBAAgB,aAAa,KAAK,EAAE,WAAW,EAAG,QAAO;AAC9D,QAAM,yBAAyB,aAAa,KAAK;AACjD,QAAM,mBAAmB,SAAS,iBAAiB,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EAAE,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AAC9G,MAAI,CAAC,iBAAiB,OAAQ,QAAO;AACrC,MAAI,iBAAiB,SAAS,GAAG,EAAG,QAAO;AAC3C,SAAO,iBAAiB,KAAK,CAAC,UAAU;AACtC,QAAI,UAAU,uBAAwB,QAAO;AAC7C,QAAI,MAAM,SAAS,IAAI,GAAG;AACxB,YAAM,SAAS,MAAM,MAAM,GAAG,EAAE;AAChC,aAAO,uBAAuB,WAAW,MAAM;AAAA,IACjD;AACA,WAAO;AAAA,EACT,CAAC;AACH;",
4
+ "sourcesContent": ["import { z } from 'zod'\n\nexport const recordLockStrategySchema = z.enum(['optimistic', 'pessimistic'])\n\nexport const recordLockSettingsSchema = z.object({\n enabled: z.boolean().default(true),\n strategy: recordLockStrategySchema.default('optimistic'),\n timeoutSeconds: z.number().int().min(30).max(3600).default(300),\n heartbeatSeconds: z.number().int().min(5).max(300).default(30),\n maxActiveLocksPerUser: z.number().int().min(1).max(500).default(50),\n enabledResources: z.array(z.string().trim().min(1)).default([]),\n allowForceUnlock: z.boolean().default(true),\n allowIncomingOverride: z.boolean().default(true),\n notifyOnConflict: z.boolean().default(true),\n})\n\nexport type RecordLockStrategy = z.infer<typeof recordLockStrategySchema>\nexport type RecordLockSettings = z.infer<typeof recordLockSettingsSchema>\n\nexport const DEFAULT_RECORD_LOCK_SETTINGS: RecordLockSettings = {\n enabled: true,\n strategy: 'optimistic',\n timeoutSeconds: 300,\n heartbeatSeconds: 30,\n maxActiveLocksPerUser: 50,\n enabledResources: ['*'],\n allowForceUnlock: true,\n allowIncomingOverride: true,\n notifyOnConflict: true,\n}\n\nexport const RECORD_LOCKS_MODULE_ID = 'record_locks'\nexport const RECORD_LOCKS_SETTINGS_NAME = 'settings'\n\nexport function normalizeRecordLockSettings(input: unknown): RecordLockSettings {\n const parsed = recordLockSettingsSchema.parse(input ?? {})\n const seen = new Set<string>()\n const enabledResources = parsed.enabledResources\n .map((resource) => resource.trim())\n .filter((resource) => {\n if (!resource) return false\n if (seen.has(resource)) return false\n seen.add(resource)\n return true\n })\n\n return {\n ...parsed,\n enabledResources,\n }\n}\n\nexport function isRecordLockingEnabledForResource(\n settings: RecordLockSettings,\n resourceKind: string | null | undefined,\n): boolean {\n if (!settings.enabled) return false\n if (!resourceKind || resourceKind.trim().length === 0) return false\n const normalizedResourceKind = resourceKind.trim()\n const enabledResources = settings.enabledResources.map((item) => item.trim()).filter((item) => item.length > 0)\n if (!enabledResources.length) return true\n if (enabledResources.includes('*')) return true\n return enabledResources.some((entry) => {\n if (entry === normalizedResourceKind) return true\n if (entry.endsWith('.*')) {\n const prefix = entry.slice(0, -1)\n return normalizedResourceKind.startsWith(prefix)\n }\n return false\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,2BAA2B,EAAE,KAAK,CAAC,cAAc,aAAa,CAAC;AAErE,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,SAAS,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA,EACjC,UAAU,yBAAyB,QAAQ,YAAY;AAAA,EACvD,gBAAgB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,IAAI,IAAI,EAAE,QAAQ,GAAG;AAAA,EAC9D,kBAAkB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EAC7D,uBAAuB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EAClE,kBAAkB,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AAAA,EAC9D,kBAAkB,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA,EAC1C,uBAAuB,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA,EAC/C,kBAAkB,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAC5C,CAAC;AAKM,MAAM,+BAAmD;AAAA,EAC9D,SAAS;AAAA,EACT,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB,kBAAkB;AAAA,EAClB,uBAAuB;AAAA,EACvB,kBAAkB,CAAC,GAAG;AAAA,EACtB,kBAAkB;AAAA,EAClB,uBAAuB;AAAA,EACvB,kBAAkB;AACpB;AAEO,MAAM,yBAAyB;AAC/B,MAAM,6BAA6B;AAEnC,SAAS,4BAA4B,OAAoC;AAC9E,QAAM,SAAS,yBAAyB,MAAM,SAAS,CAAC,CAAC;AACzD,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,mBAAmB,OAAO,iBAC7B,IAAI,CAAC,aAAa,SAAS,KAAK,CAAC,EACjC,OAAO,CAAC,aAAa;AACpB,QAAI,CAAC,SAAU,QAAO;AACtB,QAAI,KAAK,IAAI,QAAQ,EAAG,QAAO;AAC/B,SAAK,IAAI,QAAQ;AACjB,WAAO;AAAA,EACT,CAAC;AAEH,SAAO;AAAA,IACL,GAAG;AAAA,IACH;AAAA,EACF;AACF;AAEO,SAAS,kCACd,UACA,cACS;AACT,MAAI,CAAC,SAAS,QAAS,QAAO;AAC9B,MAAI,CAAC,gBAAgB,aAAa,KAAK,EAAE,WAAW,EAAG,QAAO;AAC9D,QAAM,yBAAyB,aAAa,KAAK;AACjD,QAAM,mBAAmB,SAAS,iBAAiB,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EAAE,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AAC9G,MAAI,CAAC,iBAAiB,OAAQ,QAAO;AACrC,MAAI,iBAAiB,SAAS,GAAG,EAAG,QAAO;AAC3C,SAAO,iBAAiB,KAAK,CAAC,UAAU;AACtC,QAAI,UAAU,uBAAwB,QAAO;AAC7C,QAAI,MAAM,SAAS,IAAI,GAAG;AACxB,YAAM,SAAS,MAAM,MAAM,GAAG,EAAE;AAChC,aAAO,uBAAuB,WAAW,MAAM;AAAA,IACjD;AACA,WAAO;AAAA,EACT,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -298,8 +298,8 @@ class RecordLockService {
298
298
  }
299
299
  const now = /* @__PURE__ */ new Date();
300
300
  let activeLocks = await this.findActiveLocks(input, now);
301
- const ownedActiveLock = activeLocks.find((lock2) => lock2.lockedByUserId === input.userId) ?? null;
302
- const competingActiveLock = activeLocks.find((lock2) => lock2.lockedByUserId !== input.userId) ?? null;
301
+ const ownedActiveLock = activeLocks.find((lock) => lock.lockedByUserId === input.userId) ?? null;
302
+ const competingActiveLock = activeLocks.find((lock) => lock.lockedByUserId !== input.userId) ?? null;
303
303
  if (settings.strategy === "pessimistic" && !ownedActiveLock && competingActiveLock) {
304
304
  const lockView = this.toLockView(competingActiveLock, false, activeLocks);
305
305
  if (shouldEmitLockContentionEvent({
@@ -348,25 +348,151 @@ class RecordLockService {
348
348
  lock: this.toLockView(ownedActiveLock, true, activeLocks)
349
349
  };
350
350
  }
351
- const lock = this.em.create(RecordLock, {
352
- resourceKind: input.resourceKind,
353
- resourceId: input.resourceId,
354
- token: randomUUID(),
355
- strategy: settings.strategy,
356
- status: ACTIVE_LOCK_STATUS,
357
- lockedByUserId: input.userId,
358
- lockedByIp: input.lockedByIp ?? null,
359
- baseActionLogId: latest?.id ?? null,
360
- lockedAt: now,
361
- lastHeartbeatAt: now,
362
- expiresAt: new Date(now.getTime() + settings.timeoutSeconds * 1e3),
363
- tenantId: input.tenantId,
364
- organizationId: normalizeScopeOrganization(input.organizationId)
365
- });
366
- this.em.persist(lock);
367
- let createdNewLock = true;
351
+ let activeAfterAcquire = [];
352
+ let ownedAfterAcquire = null;
353
+ let createdNewLock = false;
368
354
  try {
369
- await this.em.flush();
355
+ const outcome = await this.em.transactional(async (tx) => {
356
+ const writeEm = tx;
357
+ await this.lockUserQuotaScope(writeEm, input);
358
+ const activeLocksInTransaction = await this.findActiveLocks(input, now, writeEm);
359
+ const ownedLockInTransaction = activeLocksInTransaction.find((lock2) => lock2.lockedByUserId === input.userId) ?? null;
360
+ const competingLockInTransaction = activeLocksInTransaction.find((lock2) => lock2.lockedByUserId !== input.userId) ?? null;
361
+ if (settings.strategy === "pessimistic" && !ownedLockInTransaction && competingLockInTransaction) {
362
+ return {
363
+ result: {
364
+ ok: false,
365
+ status: 423,
366
+ error: "Record is currently locked by another user",
367
+ code: "record_locked",
368
+ allowForceUnlock: settings.allowForceUnlock,
369
+ lock: this.toLockView(competingLockInTransaction, false, activeLocksInTransaction)
370
+ },
371
+ contentionLock: competingLockInTransaction,
372
+ createdNewLock: false,
373
+ activeAfterAcquire: activeLocksInTransaction,
374
+ ownedAfterAcquire: null
375
+ };
376
+ }
377
+ if (ownedLockInTransaction) {
378
+ ownedLockInTransaction.strategy = settings.strategy;
379
+ ownedLockInTransaction.lockedByIp = input.lockedByIp ?? ownedLockInTransaction.lockedByIp ?? null;
380
+ ownedLockInTransaction.lastHeartbeatAt = now;
381
+ ownedLockInTransaction.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1e3);
382
+ await writeEm.flush();
383
+ const renewedActiveLocks = await this.findActiveLocks(input, now, writeEm);
384
+ return {
385
+ result: {
386
+ ok: true,
387
+ enabled: settings.enabled,
388
+ resourceEnabled: true,
389
+ strategy: settings.strategy,
390
+ allowForceUnlock: settings.allowForceUnlock,
391
+ heartbeatSeconds: settings.heartbeatSeconds,
392
+ acquired: false,
393
+ latestActionLogId: latest?.id ?? null,
394
+ lock: this.toLockView(ownedLockInTransaction, true, renewedActiveLocks)
395
+ },
396
+ createdNewLock: false,
397
+ activeAfterAcquire: renewedActiveLocks,
398
+ ownedAfterAcquire: ownedLockInTransaction
399
+ };
400
+ }
401
+ const activeLocksForUser = await this.countActiveLocksForUser(input, now, writeEm);
402
+ if (activeLocksForUser >= settings.maxActiveLocksPerUser) {
403
+ return {
404
+ result: {
405
+ ok: false,
406
+ status: 429,
407
+ error: "Active record lock limit reached",
408
+ code: "record_lock_quota_exceeded",
409
+ allowForceUnlock: false,
410
+ lock: null
411
+ },
412
+ createdNewLock: false,
413
+ activeAfterAcquire: activeLocksInTransaction,
414
+ ownedAfterAcquire: null
415
+ };
416
+ }
417
+ const lock = writeEm.create(RecordLock, {
418
+ resourceKind: input.resourceKind,
419
+ resourceId: input.resourceId,
420
+ token: randomUUID(),
421
+ strategy: settings.strategy,
422
+ status: ACTIVE_LOCK_STATUS,
423
+ lockedByUserId: input.userId,
424
+ lockedByIp: input.lockedByIp ?? null,
425
+ baseActionLogId: latest?.id ?? null,
426
+ lockedAt: now,
427
+ lastHeartbeatAt: now,
428
+ expiresAt: new Date(now.getTime() + settings.timeoutSeconds * 1e3),
429
+ tenantId: input.tenantId,
430
+ organizationId: normalizeScopeOrganization(input.organizationId)
431
+ });
432
+ writeEm.persist(lock);
433
+ await writeEm.flush();
434
+ const acquiredActiveLocks = await this.findActiveLocks(input, now, writeEm);
435
+ const acquiredOwnedLock = acquiredActiveLocks.find((item) => item.lockedByUserId === input.userId) ?? await this.findOwnedActiveLock(input, writeEm) ?? lock ?? null;
436
+ if (!acquiredOwnedLock) {
437
+ const fallbackLock = acquiredActiveLocks[0] ?? null;
438
+ return {
439
+ result: {
440
+ ok: true,
441
+ enabled: settings.enabled,
442
+ resourceEnabled: true,
443
+ strategy: settings.strategy,
444
+ allowForceUnlock: settings.allowForceUnlock,
445
+ heartbeatSeconds: settings.heartbeatSeconds,
446
+ acquired: false,
447
+ latestActionLogId: latest?.id ?? null,
448
+ lock: fallbackLock ? this.toLockView(fallbackLock, false, acquiredActiveLocks) : null
449
+ },
450
+ createdNewLock: false,
451
+ activeAfterAcquire: acquiredActiveLocks,
452
+ ownedAfterAcquire: null
453
+ };
454
+ }
455
+ return {
456
+ result: {
457
+ ok: true,
458
+ enabled: settings.enabled,
459
+ resourceEnabled: true,
460
+ strategy: settings.strategy,
461
+ allowForceUnlock: settings.allowForceUnlock,
462
+ heartbeatSeconds: settings.heartbeatSeconds,
463
+ acquired: true,
464
+ latestActionLogId: latest?.id ?? null,
465
+ lock: this.toLockView(acquiredOwnedLock, true, acquiredActiveLocks)
466
+ },
467
+ createdNewLock: true,
468
+ activeAfterAcquire: acquiredActiveLocks,
469
+ ownedAfterAcquire: acquiredOwnedLock
470
+ };
471
+ });
472
+ if (outcome.contentionLock && shouldEmitLockContentionEvent({
473
+ tenantId: outcome.contentionLock.tenantId,
474
+ organizationId: outcome.contentionLock.organizationId,
475
+ resourceKind: outcome.contentionLock.resourceKind,
476
+ resourceId: outcome.contentionLock.resourceId,
477
+ lockedByUserId: outcome.contentionLock.lockedByUserId,
478
+ attemptedByUserId: input.userId
479
+ })) {
480
+ await emitRecordLocksEvent("record_locks.lock.contended", {
481
+ lockId: outcome.contentionLock.id,
482
+ resourceKind: outcome.contentionLock.resourceKind,
483
+ resourceId: outcome.contentionLock.resourceId,
484
+ tenantId: outcome.contentionLock.tenantId,
485
+ organizationId: outcome.contentionLock.organizationId,
486
+ lockedByUserId: outcome.contentionLock.lockedByUserId,
487
+ attemptedByUserId: input.userId
488
+ });
489
+ }
490
+ activeAfterAcquire = outcome.activeAfterAcquire;
491
+ ownedAfterAcquire = outcome.ownedAfterAcquire;
492
+ createdNewLock = outcome.createdNewLock;
493
+ if (!outcome.result.ok || !createdNewLock) {
494
+ return outcome.result;
495
+ }
370
496
  } catch (error) {
371
497
  if (!isActiveLockScopeUniqueViolation(error)) throw error;
372
498
  const clear = this.em.clear;
@@ -408,12 +534,8 @@ class RecordLockService {
408
534
  existingOwned.lastHeartbeatAt = now;
409
535
  existingOwned.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1e3);
410
536
  await this.em.flush();
411
- createdNewLock = false;
412
- }
413
- const activeAfterAcquire = await this.findActiveLocks(input, now);
414
- const ownedAfterAcquire = activeAfterAcquire.find((item) => item.lockedByUserId === input.userId) ?? await this.findOwnedActiveLock(input) ?? lock ?? null;
415
- if (!ownedAfterAcquire) {
416
- const fallbackLock = activeAfterAcquire[0] ?? null;
537
+ activeAfterAcquire = await this.findActiveLocks(input, now);
538
+ ownedAfterAcquire = existingOwned;
417
539
  return {
418
540
  ok: true,
419
541
  enabled: settings.enabled,
@@ -423,10 +545,10 @@ class RecordLockService {
423
545
  heartbeatSeconds: settings.heartbeatSeconds,
424
546
  acquired: false,
425
547
  latestActionLogId: latest?.id ?? null,
426
- lock: fallbackLock ? this.toLockView(fallbackLock, false, activeAfterAcquire) : null
548
+ lock: this.toLockView(existingOwned, true, activeAfterAcquire)
427
549
  };
428
550
  }
429
- if (createdNewLock) {
551
+ if (createdNewLock && ownedAfterAcquire) {
430
552
  await emitRecordLocksEvent("record_locks.lock.acquired", {
431
553
  lockId: ownedAfterAcquire.id,
432
554
  resourceKind: ownedAfterAcquire.resourceKind,
@@ -470,7 +592,7 @@ class RecordLockService {
470
592
  heartbeatSeconds: settings.heartbeatSeconds,
471
593
  acquired: createdNewLock,
472
594
  latestActionLogId: latest?.id ?? null,
473
- lock: this.toLockView(ownedAfterAcquire, true, activeAfterAcquire)
595
+ lock: ownedAfterAcquire ? this.toLockView(ownedAfterAcquire, true, activeAfterAcquire) : null
474
596
  };
475
597
  }
476
598
  async heartbeat(input) {
@@ -983,7 +1105,7 @@ class RecordLockService {
983
1105
  }
984
1106
  return where;
985
1107
  }
986
- async findActiveLocks(input, now) {
1108
+ async findActiveLocks(input, now, em = this.em) {
987
1109
  const legacyFinder = this.findActiveLock;
988
1110
  if (typeof legacyFinder === "function") {
989
1111
  const legacyResult = await legacyFinder(input, now);
@@ -995,7 +1117,7 @@ class RecordLockService {
995
1117
  resourceId: input.resourceId,
996
1118
  status: ACTIVE_LOCK_STATUS
997
1119
  };
998
- const locks = await this.em.find(RecordLock, where, { orderBy: { updatedAt: "desc" } });
1120
+ const locks = await em.find(RecordLock, where, { orderBy: { updatedAt: "desc" } });
999
1121
  if (!Array.isArray(locks) || !locks.length) return [];
1000
1122
  let dirty = false;
1001
1123
  const active = [];
@@ -1014,7 +1136,7 @@ class RecordLockService {
1014
1136
  }
1015
1137
  active.push(lock);
1016
1138
  }
1017
- if (dirty) await this.em.flush();
1139
+ if (dirty) await em.flush();
1018
1140
  if (expiredLocks.length) {
1019
1141
  const recipientUserIds = active.map((lock) => lock.lockedByUserId);
1020
1142
  for (const expiredLock of expiredLocks) {
@@ -1033,6 +1155,25 @@ class RecordLockService {
1033
1155
  }
1034
1156
  return active;
1035
1157
  }
1158
+ async countActiveLocksForUser(input, now, em = this.em) {
1159
+ const where = {
1160
+ ...this.buildScopeWhere(input),
1161
+ lockedByUserId: input.userId,
1162
+ status: ACTIVE_LOCK_STATUS,
1163
+ expiresAt: { $gt: now }
1164
+ };
1165
+ return em.count(RecordLock, where);
1166
+ }
1167
+ async lockUserQuotaScope(em, input) {
1168
+ const lockKey = [
1169
+ "record_locks",
1170
+ "quota",
1171
+ input.tenantId,
1172
+ normalizeScopeOrganization(input.organizationId) ?? "global",
1173
+ input.userId
1174
+ ].join(":");
1175
+ await em.getConnection().execute("select pg_advisory_xact_lock(hashtext(?))", [lockKey]);
1176
+ }
1036
1177
  async findOwnedLockByToken(input) {
1037
1178
  if (!input.token) return null;
1038
1179
  const where = {
@@ -1045,7 +1186,7 @@ class RecordLockService {
1045
1186
  };
1046
1187
  return this.em.findOne(RecordLock, where);
1047
1188
  }
1048
- async findOwnedActiveLock(input) {
1189
+ async findOwnedActiveLock(input, em = this.em) {
1049
1190
  const where = {
1050
1191
  ...this.buildScopeWhere(input),
1051
1192
  resourceKind: input.resourceKind,
@@ -1053,7 +1194,7 @@ class RecordLockService {
1053
1194
  lockedByUserId: input.userId,
1054
1195
  status: ACTIVE_LOCK_STATUS
1055
1196
  };
1056
- return this.em.findOne(RecordLock, where);
1197
+ return em.findOne(RecordLock, where);
1057
1198
  }
1058
1199
  async hasRecentSavedRelease(input) {
1059
1200
  const cutoff = new Date(input.now.getTime() - PARTICIPANT_REJOIN_AFTER_SAVE_SUPPRESS_MS);