@open-mercato/enterprise 0.6.6-develop.6474.1.2c411ad42b → 0.6.6-develop.6480.1.309f47ca1c
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/dist/modules/record_locks/api/acquire/route.js +2 -1
- package/dist/modules/record_locks/api/acquire/route.js.map +2 -2
- package/dist/modules/record_locks/lib/config.js +2 -0
- package/dist/modules/record_locks/lib/config.js.map +2 -2
- package/dist/modules/record_locks/lib/recordLockService.js +175 -34
- package/dist/modules/record_locks/lib/recordLockService.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/record_locks/api/acquire/route.ts +1 -0
- package/src/modules/record_locks/lib/config.ts +2 -0
- package/src/modules/record_locks/lib/recordLockService.ts +203 -36
|
@@ -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": "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
|
|
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": "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",
|
|
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((
|
|
302
|
-
const competingActiveLock = activeLocks.find((
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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);
|