import { randomUUID } from 'crypto' import { UniqueConstraintViolationException, type FilterQuery } from '@mikro-orm/core' import type { EntityManager } from '@mikro-orm/postgresql' import { type Kysely, sql } from 'kysely' import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service' import { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities' import type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService' import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService' import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService' import { emitRecordLocksEvent } from '../events' import { RecordLock, RecordLockConflict, type RecordLockStatus, type RecordLockReleaseReason, type RecordLockConflictResolution, type RecordLockConflictStatus, } from '../data/entities' import { recordLockMutationHeaderSchema, type RecordLockMutationHeaders, type RecordLockReleaseInput as RecordLockReleasePayloadInput, type RecordLockSettingsInput, } from '../data/validators' import { DEFAULT_RECORD_LOCK_SETTINGS, RECORD_LOCKS_MODULE_ID, RECORD_LOCKS_SETTINGS_NAME, isRecordLockingEnabledForResource, normalizeRecordLockSettings, type RecordLockSettings, type RecordLockStrategy, } from './config' import { isRecordValue, valuesEqual } from './conflictValues' const ACTIVE_LOCK_STATUS: RecordLockStatus = 'active' const ACTIVE_SCOPE_UNIQUE_CONSTRAINTS = new Set([ 'record_locks_active_scope_org_unique', 'record_locks_active_scope_tenant_unique', 'record_locks_active_scope_user_org_unique', 'record_locks_active_scope_user_tenant_unique', ]) const LOCK_CONTENTION_EVENT_TTL_MS = 15_000 const PARTICIPANT_REJOIN_AFTER_SAVE_SUPPRESS_MS = 20_000 const LOCK_CONTENTION_EVENT_MAX_ENTRIES = 2_000 const lockContentionEventThrottle = new Map() const LOCK_CLEANUP_INTERVAL_MS = 5 * 60 * 1000 const LOCK_RETENTION_MS = 3 * 24 * 60 * 60 * 1000 const RESOLVED_CONFLICT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 const PENDING_CONFLICT_RETENTION_MS = 24 * 60 * 60 * 1000 const LOCK_CLEANUP_STATE_TTL_MS = 24 * 60 * 60 * 1000 const LOCK_CLEANUP_STATE_MAX_ENTRIES = 2_000 const lockCleanupStateByTenant = new Map() export type RecordLockScope = { tenantId: string organizationId?: string | null userId: string } export type RecordLockResource = { resourceKind: string resourceId: string } export type RecordLockAcquireInput = RecordLockScope & RecordLockResource & { lockedByIp?: string | null } export type RecordLockHeartbeatInput = RecordLockScope & RecordLockResource & { token: string } export type RecordLockReleaseInput = RecordLockScope & RecordLockResource & { token?: string reason?: Exclude } & Pick export type RecordLockForceReleaseInput = RecordLockScope & RecordLockResource & { reason?: string | null } export type RecordLockMutationValidationInput = RecordLockScope & RecordLockResource & { method: 'PUT' | 'DELETE' headers: Partial mutationPayload?: Record | null } export type RecordLockConflictChange = { field: string displayValue: unknown baseValue: unknown incomingValue: unknown mineValue: unknown } export type RecordLockConflictPayload = { id: string resourceKind: string resourceId: string baseActionLogId: string | null incomingActionLogId: string | null allowIncomingOverride: boolean canOverrideIncoming: boolean resolutionOptions: Array<'accept_mine'> changes: RecordLockConflictChange[] } export type RecordLockView = { id: string resourceKind: string resourceId: string token: string | null strategy: RecordLockStrategy status: RecordLockStatus lockedByUserId: string lockedByIp: string | null baseActionLogId: string | null lockedAt: string lastHeartbeatAt: string expiresAt: string participants: RecordLockParticipantView[] activeParticipantCount: number } export type RecordLockParticipantView = { userId: string lockedByIp: string | null lockedAt: string lastHeartbeatAt: string expiresAt: string } export type RecordLockAcquireResult = { ok: true enabled: boolean resourceEnabled: boolean strategy: RecordLockStrategy allowForceUnlock: boolean heartbeatSeconds: number acquired: boolean latestActionLogId: string | null lock: RecordLockView | null } export type RecordLockAcquireFailure = Omit & { status: RecordLockValidationFailure['status'] | 429 code: RecordLockValidationFailure['code'] | 'record_lock_quota_exceeded' allowForceUnlock: boolean } export type RecordLockHeartbeatResult = { ok: true expiresAt: string | null } export type RecordLockReleaseResult = { ok: true released: boolean conflictResolved: boolean } export type RecordLockForceReleaseResult = { ok: true released: boolean lock: RecordLockView | null } export type RecordLockValidationSuccess = { ok: true enabled: boolean resourceEnabled: boolean strategy: RecordLockStrategy shouldReleaseOnSuccess: boolean lock: RecordLockView | null latestActionLogId: string | null } export type RecordLockValidationFailure = { ok: false status: 409 | 423 error: string code: 'record_lock_conflict' | 'record_locked' lock: RecordLockView | null conflict?: RecordLockConflictPayload } export type RecordLockValidationResult = RecordLockValidationSuccess | RecordLockValidationFailure export type RecordLockServiceDeps = { em: EntityManager moduleConfigService?: ModuleConfigService | null actionLogService?: ActionLogService | null rbacService?: RbacService | null } export type ParsedRecordLockHeaders = Partial function normalizeDate(value: Date): string { return value.toISOString() } function trimToNull(value: string | null | undefined): string | null { if (typeof value !== 'string') return null const trimmed = value.trim() return trimmed.length ? trimmed : null } function normalizeScopeOrganization(value: string | null | undefined): string | null { const trimmed = trimToNull(value) return trimmed ?? null } function shouldEmitLockContentionEvent(input: { tenantId: string organizationId?: string | null resourceKind: string resourceId: string lockedByUserId: string attemptedByUserId: string }): boolean { if (process.env.NODE_ENV === 'test') return true const now = Date.now() const key = [ input.tenantId, normalizeScopeOrganization(input.organizationId) ?? 'global', input.resourceKind, input.resourceId, input.lockedByUserId, input.attemptedByUserId, ].join('|') const lastEmittedAt = lockContentionEventThrottle.get(key) if (typeof lastEmittedAt === 'number' && now - lastEmittedAt < LOCK_CONTENTION_EVENT_TTL_MS) { return false } lockContentionEventThrottle.set(key, now) for (const [cachedKey, cachedAt] of lockContentionEventThrottle.entries()) { if (now - cachedAt > LOCK_CONTENTION_EVENT_TTL_MS) { lockContentionEventThrottle.delete(cachedKey) } } if (lockContentionEventThrottle.size > LOCK_CONTENTION_EVENT_MAX_ENTRIES) { const oldest = Array.from(lockContentionEventThrottle.entries()) .sort((left, right) => left[1] - right[1]) .slice(0, lockContentionEventThrottle.size - LOCK_CONTENTION_EVENT_MAX_ENTRIES) for (const [staleKey] of oldest) lockContentionEventThrottle.delete(staleKey) } return true } function isActiveLockScopeUniqueViolation(error: unknown): boolean { if (error instanceof UniqueConstraintViolationException) { const errorWithConstraint = error as unknown as { constraint?: unknown } const constraint = typeof errorWithConstraint.constraint === 'string' ? errorWithConstraint.constraint : null if (constraint && ACTIVE_SCOPE_UNIQUE_CONSTRAINTS.has(constraint)) return true } if (!error || typeof error !== 'object') return false const code = (error as { code?: unknown }).code if (code !== '23505') return false const message = typeof (error as { message?: unknown }).message === 'string' ? (error as { message: string }).message.toLowerCase() : '' for (const constraint of ACTIVE_SCOPE_UNIQUE_CONSTRAINTS) { if (message.includes(constraint)) return true } return false } function getKysely(em: EntityManager): Kysely { return (em as unknown as { getKysely: () => Kysely }).getKysely() } const SKIPPED_CONFLICT_FIELDS = new Set([ 'updatedAt', 'updated_at', 'createdAt', 'created_at', 'deletedAt', 'deleted_at', ]) function shouldSkipConflictField(path: string): boolean { if (!path.trim().length) return true if (SKIPPED_CONFLICT_FIELDS.has(path)) return true const segments = path.split('.').filter((segment) => segment.length > 0) if (!segments.length) return true return SKIPPED_CONFLICT_FIELDS.has(segments[segments.length - 1] ?? '') } const MISSING_CONFLICT_VALUE = Symbol('record_lock_conflict_missing_value') function parseDecryptedJsonLike(value: unknown): unknown { return typeof value === 'string' ? parseDecryptedFieldValue(value) : value } function readJsonRecordValue(value: unknown): Record | null { const parsed = parseDecryptedJsonLike(value) return isRecordValue(parsed) ? parsed : null } function readActionLogChangesJson(log: ActionLog | null): Record | null { return readJsonRecordValue(log?.changesJson ?? null) } function normalizeActionLogPayload(log: ActionLog | null): ActionLog | null { if (!log) return null log.changesJson = readJsonRecordValue(log.changesJson) log.snapshotBefore = parseDecryptedJsonLike(log.snapshotBefore) log.snapshotAfter = parseDecryptedJsonLike(log.snapshotAfter) log.contextJson = readJsonRecordValue(log.contextJson) log.commandPayload = parseDecryptedJsonLike(log.commandPayload) return log } function readPathValue(source: unknown, path: string): unknown | typeof MISSING_CONFLICT_VALUE { if (!path.trim().length || !isRecordValue(source)) return MISSING_CONFLICT_VALUE if (Object.prototype.hasOwnProperty.call(source, path)) return source[path] const segments = path.split('.').filter((segment) => segment.length > 0) if (!segments.length) return MISSING_CONFLICT_VALUE let current: unknown = source for (const segment of segments) { if (!isRecordValue(current)) return MISSING_CONFLICT_VALUE if (!Object.prototype.hasOwnProperty.call(current, segment)) return MISSING_CONFLICT_VALUE current = current[segment] } return current } function buildPathVariants(path: string): string[] { const trimmed = path.trim() if (!trimmed.length) return [] const segments = trimmed.split('.').filter((segment) => segment.length > 0) if (segments.length <= 1) return [trimmed] const variants = new Set([trimmed]) for (let index = 1; index < segments.length; index += 1) { variants.add(segments.slice(index).join('.')) } return Array.from(variants) } function readPathValueLoose(source: unknown, path: string): unknown | typeof MISSING_CONFLICT_VALUE { const variants = buildPathVariants(path) for (const variant of variants) { const value = readPathValue(source, variant) if (value !== MISSING_CONFLICT_VALUE) return value } return MISSING_CONFLICT_VALUE } function normalizeConflictValue(value: unknown): unknown { return value === undefined ? null : value } function formatNotificationValue(value: unknown): string { if (value === null || value === undefined) return '-' if (typeof value === 'string') return value if (typeof value === 'number' || typeof value === 'boolean') return String(value) if (value instanceof Date) return value.toISOString() try { return JSON.stringify(value) } catch { return String(value) } } function formatChangedFieldLabel(rawField: string): string { const trimmedField = rawField.trim() const withoutNamespace = trimmedField.includes('::') ? (trimmedField.split('::').pop() ?? trimmedField) : trimmedField const withoutPrefix = withoutNamespace.includes('.') ? (withoutNamespace.split('.').pop() ?? withoutNamespace) : withoutNamespace const words = withoutPrefix .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[._-]+/g, ' ') .replace(/\s+/g, ' ') .trim() .split(' ') .filter(Boolean) if (!words.length) return trimmedField return words .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') } export function readRecordLockHeaders(headers: Headers): ParsedRecordLockHeaders { const raw = { resourceKind: trimToNull(headers.get('x-om-record-lock-kind')) ?? undefined, resourceId: trimToNull(headers.get('x-om-record-lock-resource-id')) ?? undefined, token: trimToNull(headers.get('x-om-record-lock-token')) ?? undefined, baseLogId: trimToNull(headers.get('x-om-record-lock-base-log-id')) ?? undefined, resolution: trimToNull(headers.get('x-om-record-lock-resolution')) ?? undefined, conflictId: trimToNull(headers.get('x-om-record-lock-conflict-id')) ?? undefined, } const parsed = recordLockMutationHeaderSchema.partial().safeParse(raw) if (!parsed.success) return {} return parsed.data } export class RecordLockService { private readonly em: EntityManager private readonly moduleConfigService: ModuleConfigService | null private readonly actionLogService: ActionLogService | null private readonly rbacService: RbacService | null constructor(deps: RecordLockServiceDeps) { this.em = deps.em this.moduleConfigService = deps.moduleConfigService ?? null this.actionLogService = deps.actionLogService ?? null this.rbacService = deps.rbacService ?? null } async getSettings(): Promise { if (!this.moduleConfigService) return DEFAULT_RECORD_LOCK_SETTINGS const value = await this.moduleConfigService.getValue( RECORD_LOCKS_MODULE_ID, RECORD_LOCKS_SETTINGS_NAME, { defaultValue: DEFAULT_RECORD_LOCK_SETTINGS }, ) return normalizeRecordLockSettings(value ?? DEFAULT_RECORD_LOCK_SETTINGS) } async saveSettings(input: RecordLockSettingsInput): Promise { const settings = normalizeRecordLockSettings(input) if (!this.moduleConfigService) return settings await this.moduleConfigService.setValue(RECORD_LOCKS_MODULE_ID, RECORD_LOCKS_SETTINGS_NAME, settings) return settings // NOSONAR — both paths return settings by design; the branch controls persistence } /** * Command-guard `resolveExpected` seam (Phase 0 / S1). Derives the expected * version token the OSS command floor compares against the record's current * server-side `updated_at`. * * The compare is server-authoritative: the command handler already loaded the * record from the DB and passes its `updated_at` as `current`, so a stale * write is detected from the version header alone — NEVER requiring a client * record-lock token (H2). When record_locks is not enabled for the resource * (settings off), this returns the header token unchanged so the floor still * runs (pure floor behavior). The richer pessimistic/action-log conflict * detection runs through `validateMutation` at the CRUD layer. */ async resolveExpectedVersion(input: { resourceKind: string expectedFromHeader: string | null }): Promise { // Enabled or not, the server-authoritative floor compares the header token // against the DB `updated_at`, so resolution is unconditionally the header // value. Gating (record_locks settings) only changes whether the richer // CRUD-layer conflict enrichment runs in `validateMutation` — it never // affects the version floor here. return input.expectedFromHeader } async acquire(input: RecordLockAcquireInput): Promise { this.scheduleCleanup(input.tenantId) const settings = await this.getSettings() const latest = await this.findLatestActionLogWithScopeFallback(input) const resourceEnabled = isRecordLockingEnabledForResource(settings, input.resourceKind) if (!resourceEnabled) { return { ok: true, enabled: settings.enabled, resourceEnabled: false, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: false, latestActionLogId: latest?.id ?? null, lock: null, } } const now = new Date() let activeLocks = await this.findActiveLocks(input, now) const ownedActiveLock = activeLocks.find((lock) => lock.lockedByUserId === input.userId) ?? null const competingActiveLock = activeLocks.find((lock) => lock.lockedByUserId !== input.userId) ?? null if (settings.strategy === 'pessimistic' && !ownedActiveLock && competingActiveLock) { const lockView = this.toLockView(competingActiveLock, false, activeLocks) if (shouldEmitLockContentionEvent({ tenantId: competingActiveLock.tenantId, organizationId: competingActiveLock.organizationId, resourceKind: competingActiveLock.resourceKind, resourceId: competingActiveLock.resourceId, lockedByUserId: competingActiveLock.lockedByUserId, attemptedByUserId: input.userId, })) { await emitRecordLocksEvent('record_locks.lock.contended', { lockId: competingActiveLock.id, resourceKind: competingActiveLock.resourceKind, resourceId: competingActiveLock.resourceId, tenantId: competingActiveLock.tenantId, organizationId: competingActiveLock.organizationId, lockedByUserId: competingActiveLock.lockedByUserId, attemptedByUserId: input.userId, }) } return { ok: false, status: 423, error: 'Record is currently locked by another user', code: 'record_locked', allowForceUnlock: settings.allowForceUnlock, lock: lockView, } } if (ownedActiveLock) { ownedActiveLock.strategy = settings.strategy ownedActiveLock.lockedByIp = input.lockedByIp ?? ownedActiveLock.lockedByIp ?? null ownedActiveLock.lastHeartbeatAt = now ownedActiveLock.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1000) await this.em.flush() activeLocks = await this.findActiveLocks(input, now) return { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: false, latestActionLogId: latest?.id ?? null, lock: this.toLockView(ownedActiveLock, true, activeLocks), } } let activeAfterAcquire: RecordLock[] = [] let ownedAfterAcquire: RecordLock | null = null let createdNewLock = false try { const outcome = await this.em.transactional(async (tx) => { const writeEm = tx as EntityManager await this.lockUserQuotaScope(writeEm, input) const activeLocksInTransaction = await this.findActiveLocks(input, now, writeEm) const ownedLockInTransaction = activeLocksInTransaction.find((lock) => lock.lockedByUserId === input.userId) ?? null const competingLockInTransaction = activeLocksInTransaction.find((lock) => lock.lockedByUserId !== input.userId) ?? null if (settings.strategy === 'pessimistic' && !ownedLockInTransaction && competingLockInTransaction) { return { result: { ok: false, status: 423, error: 'Record is currently locked by another user', code: 'record_locked', allowForceUnlock: settings.allowForceUnlock, lock: this.toLockView(competingLockInTransaction, false, activeLocksInTransaction), } satisfies RecordLockAcquireFailure, contentionLock: competingLockInTransaction, createdNewLock: false, activeAfterAcquire: activeLocksInTransaction, ownedAfterAcquire: null, } } if (ownedLockInTransaction) { ownedLockInTransaction.strategy = settings.strategy ownedLockInTransaction.lockedByIp = input.lockedByIp ?? ownedLockInTransaction.lockedByIp ?? null ownedLockInTransaction.lastHeartbeatAt = now ownedLockInTransaction.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1000) await writeEm.flush() const renewedActiveLocks = await this.findActiveLocks(input, now, writeEm) return { result: { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: false, latestActionLogId: latest?.id ?? null, lock: this.toLockView(ownedLockInTransaction, true, renewedActiveLocks), } satisfies RecordLockAcquireResult, createdNewLock: false, activeAfterAcquire: renewedActiveLocks, ownedAfterAcquire: ownedLockInTransaction, } } const activeLocksForUser = await this.countActiveLocksForUser(input, now, writeEm) if (activeLocksForUser >= settings.maxActiveLocksPerUser) { return { result: { ok: false, status: 429, error: 'Active record lock limit reached', code: 'record_lock_quota_exceeded', allowForceUnlock: false, lock: null, } satisfies RecordLockAcquireFailure, createdNewLock: false, activeAfterAcquire: activeLocksInTransaction, ownedAfterAcquire: null, } } const lock = writeEm.create(RecordLock, { resourceKind: input.resourceKind, resourceId: input.resourceId, token: randomUUID(), strategy: settings.strategy, status: ACTIVE_LOCK_STATUS, lockedByUserId: input.userId, lockedByIp: input.lockedByIp ?? null, baseActionLogId: latest?.id ?? null, lockedAt: now, lastHeartbeatAt: now, expiresAt: new Date(now.getTime() + settings.timeoutSeconds * 1000), tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId), }) writeEm.persist(lock) await writeEm.flush() const acquiredActiveLocks = await this.findActiveLocks(input, now, writeEm) const acquiredOwnedLock = acquiredActiveLocks.find((item) => item.lockedByUserId === input.userId) ?? await this.findOwnedActiveLock(input, writeEm) ?? lock ?? null if (!acquiredOwnedLock) { const fallbackLock = acquiredActiveLocks[0] ?? null return { result: { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: false, latestActionLogId: latest?.id ?? null, lock: fallbackLock ? this.toLockView(fallbackLock, false, acquiredActiveLocks) : null, } satisfies RecordLockAcquireResult, createdNewLock: false, activeAfterAcquire: acquiredActiveLocks, ownedAfterAcquire: null, } } return { result: { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: true, latestActionLogId: latest?.id ?? null, lock: this.toLockView(acquiredOwnedLock, true, acquiredActiveLocks), } satisfies RecordLockAcquireResult, createdNewLock: true, activeAfterAcquire: acquiredActiveLocks, ownedAfterAcquire: acquiredOwnedLock, } }) if (outcome.contentionLock && shouldEmitLockContentionEvent({ tenantId: outcome.contentionLock.tenantId, organizationId: outcome.contentionLock.organizationId, resourceKind: outcome.contentionLock.resourceKind, resourceId: outcome.contentionLock.resourceId, lockedByUserId: outcome.contentionLock.lockedByUserId, attemptedByUserId: input.userId, })) { await emitRecordLocksEvent('record_locks.lock.contended', { lockId: outcome.contentionLock.id, resourceKind: outcome.contentionLock.resourceKind, resourceId: outcome.contentionLock.resourceId, tenantId: outcome.contentionLock.tenantId, organizationId: outcome.contentionLock.organizationId, lockedByUserId: outcome.contentionLock.lockedByUserId, attemptedByUserId: input.userId, }) } activeAfterAcquire = outcome.activeAfterAcquire ownedAfterAcquire = outcome.ownedAfterAcquire createdNewLock = outcome.createdNewLock if (!outcome.result.ok || !createdNewLock) { return outcome.result } } catch (error) { if (!isActiveLockScopeUniqueViolation(error)) throw error const clear = (this.em as { clear?: () => void }).clear if (typeof clear === 'function') clear.call(this.em) const locksAfterCollision = await this.findActiveLocks(input, now) const competingAfterCollision = locksAfterCollision.find((item) => item.lockedByUserId !== input.userId) ?? null if (settings.strategy === 'pessimistic' && competingAfterCollision) { if (shouldEmitLockContentionEvent({ tenantId: competingAfterCollision.tenantId, organizationId: competingAfterCollision.organizationId, resourceKind: competingAfterCollision.resourceKind, resourceId: competingAfterCollision.resourceId, lockedByUserId: competingAfterCollision.lockedByUserId, attemptedByUserId: input.userId, })) { await emitRecordLocksEvent('record_locks.lock.contended', { lockId: competingAfterCollision.id, resourceKind: competingAfterCollision.resourceKind, resourceId: competingAfterCollision.resourceId, tenantId: competingAfterCollision.tenantId, organizationId: competingAfterCollision.organizationId, lockedByUserId: competingAfterCollision.lockedByUserId, attemptedByUserId: input.userId, }) } return { ok: false, status: 423, error: 'Record is currently locked by another user', code: 'record_locked', allowForceUnlock: settings.allowForceUnlock, lock: this.toLockView(competingAfterCollision, false, locksAfterCollision), } } const existingOwned = await this.findOwnedActiveLock(input) if (!existingOwned) throw error existingOwned.strategy = settings.strategy existingOwned.lockedByIp = input.lockedByIp ?? existingOwned.lockedByIp ?? null existingOwned.lastHeartbeatAt = now existingOwned.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1000) await this.em.flush() activeAfterAcquire = await this.findActiveLocks(input, now) ownedAfterAcquire = existingOwned return { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: false, latestActionLogId: latest?.id ?? null, lock: this.toLockView(existingOwned, true, activeAfterAcquire), } } if (createdNewLock && ownedAfterAcquire) { await emitRecordLocksEvent('record_locks.lock.acquired', { lockId: ownedAfterAcquire.id, resourceKind: ownedAfterAcquire.resourceKind, resourceId: ownedAfterAcquire.resourceId, tenantId: ownedAfterAcquire.tenantId, organizationId: ownedAfterAcquire.organizationId, lockedByUserId: ownedAfterAcquire.lockedByUserId, strategy: ownedAfterAcquire.strategy, baseActionLogId: ownedAfterAcquire.baseActionLogId, activeParticipantCount: activeAfterAcquire.length, }) const recipientUserIds = activeAfterAcquire .filter((item) => item.lockedByUserId !== input.userId) .map((item) => item.lockedByUserId) const shouldSuppressJoinNotification = await this.hasRecentSavedRelease({ tenantId: input.tenantId, organizationId: input.organizationId, userId: input.userId, resourceKind: input.resourceKind, resourceId: input.resourceId, now, }) if (!shouldSuppressJoinNotification) { await emitRecordLocksEvent('record_locks.participant.joined', { lockId: ownedAfterAcquire.id, resourceKind: ownedAfterAcquire.resourceKind, resourceId: ownedAfterAcquire.resourceId, tenantId: ownedAfterAcquire.tenantId, organizationId: ownedAfterAcquire.organizationId, joinedUserId: input.userId, joinedIp: ownedAfterAcquire.lockedByIp ?? null, recipientUserIds, activeParticipantCount: activeAfterAcquire.length, }) } } return { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, allowForceUnlock: settings.allowForceUnlock, heartbeatSeconds: settings.heartbeatSeconds, acquired: createdNewLock, latestActionLogId: latest?.id ?? null, lock: ownedAfterAcquire ? this.toLockView(ownedAfterAcquire, true, activeAfterAcquire) : null, } } async heartbeat(input: RecordLockHeartbeatInput): Promise { const settings = await this.getSettings() const resourceEnabled = isRecordLockingEnabledForResource(settings, input.resourceKind) if (!resourceEnabled) return { ok: true, expiresAt: null } const lock = await this.findOwnedLockByToken(input) if (!lock) return { ok: true, expiresAt: null } const now = new Date() if (lock.expiresAt <= now) { this.markLockReleased(lock, { status: 'expired', reason: 'expired', releasedByUserId: lock.lockedByUserId, now, }) await this.em.flush() return { ok: true, expiresAt: null } } lock.lastHeartbeatAt = now lock.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1000) await this.em.flush() return { ok: true, expiresAt: normalizeDate(lock.expiresAt) } } async release(input: RecordLockReleaseInput): Promise { const settings = await this.getSettings() const resourceEnabled = isRecordLockingEnabledForResource(settings, input.resourceKind) if (!resourceEnabled) return { ok: true, released: false, conflictResolved: false } let conflictResolved = false if (input.reason === 'conflict_resolved' && input.conflictId && input.resolution === 'accept_incoming') { const conflict = await this.findConflictById(input.conflictId, input) if (conflict && conflict.status === 'pending' && conflict.conflictActorUserId === input.userId) { await this.resolveConflict(conflict, input.resolution, input.userId) conflictResolved = true } } const lock = input.token ? await this.findOwnedLockByToken(input) : await this.findOwnedActiveLock(input) if (!lock) return { ok: true, released: false, conflictResolved } const now = new Date() this.markLockReleased(lock, { status: 'released', reason: input.reason ?? 'cancelled', releasedByUserId: input.userId, now, }) await this.em.flush() await emitRecordLocksEvent('record_locks.lock.released', { lockId: lock.id, resourceKind: lock.resourceKind, resourceId: lock.resourceId, tenantId: lock.tenantId, organizationId: lock.organizationId, lockedByUserId: lock.lockedByUserId, releasedByUserId: input.userId, reason: lock.releaseReason, }) if (lock.releaseReason === 'unmount') { const remainingActiveLocks = await this.findActiveLocks(input, now) const recipientUserIds = remainingActiveLocks .map((activeLock) => activeLock.lockedByUserId) .filter((userId) => userId !== lock.lockedByUserId) if (recipientUserIds.length) { await emitRecordLocksEvent('record_locks.participant.left', { lockId: lock.id, resourceKind: lock.resourceKind, resourceId: lock.resourceId, tenantId: lock.tenantId, organizationId: lock.organizationId, leftUserId: lock.lockedByUserId, reason: 'unmount', recipientUserIds, activeParticipantCount: remainingActiveLocks.length, }) } } return { ok: true, released: true, conflictResolved } } async forceRelease(input: RecordLockForceReleaseInput): Promise { const settings = await this.getSettings() const resourceEnabled = isRecordLockingEnabledForResource(settings, input.resourceKind) const canForceRelease = await this.canUserForceRelease(input, settings) if (!resourceEnabled || !settings.allowForceUnlock || !canForceRelease) { return { ok: true, released: false, lock: null } } const now = new Date() const activeLocks = this.sortLocksByJoinOrder(await this.findActiveLocks(input, now)) const lock = activeLocks[0] ?? null if (!lock) return { ok: true, released: false, lock: null } this.markLockReleased(lock, { status: 'force_released', reason: 'force', releasedByUserId: input.userId, now, }) await this.em.flush() await emitRecordLocksEvent('record_locks.lock.force_released', { lockId: lock.id, resourceKind: lock.resourceKind, resourceId: lock.resourceId, tenantId: lock.tenantId, organizationId: lock.organizationId, lockedByUserId: lock.lockedByUserId, releasedByUserId: input.userId, reason: input.reason ?? null, }) const remainingLocks = this.sortLocksByJoinOrder(activeLocks.filter((item) => item.id !== lock.id)) const nextInQueue = remainingLocks[0] ?? null return { ok: true, released: true, lock: nextInQueue ? this.toLockView(nextInQueue, false, remainingLocks) : null } } async validateMutation(input: RecordLockMutationValidationInput): Promise { this.scheduleCleanup(input.tenantId) const settings = await this.getSettings() const resourceEnabled = isRecordLockingEnabledForResource(settings, input.resourceKind) const canOverrideIncoming = await this.canUserOverrideIncoming(input, settings) if (!resourceEnabled) { return { ok: true, enabled: settings.enabled, resourceEnabled: false, strategy: settings.strategy, shouldReleaseOnSuccess: false, lock: null, latestActionLogId: null, } } const parsedHeaders = this.normalizeMutationHeaders(input.headers) const keepMineResolution = parsedHeaders.resolution === 'accept_mine' || parsedHeaders.resolution === 'merged' ? parsedHeaders.resolution : null const hasKeepMineIntent = keepMineResolution !== null const now = new Date() const activeLocks = await this.findActiveLocks(input, now) const ownedActiveLock = activeLocks.find((lock) => lock.lockedByUserId === input.userId) ?? null const competingLock = activeLocks.find((lock) => lock.lockedByUserId !== input.userId) ?? null const latest = await this.findLatestActionLogWithScopeFallback(input) const shouldReleaseOnSuccess = Boolean( ownedActiveLock && (!parsedHeaders.token || ownedActiveLock.token === parsedHeaders.token), ) if (settings.strategy === 'pessimistic') { if (competingLock && !ownedActiveLock) { return { ok: false, status: 423, error: 'Record is currently locked by another user', code: 'record_locked', lock: this.toLockView(competingLock, false, activeLocks), } } if (ownedActiveLock) { if (parsedHeaders.token && ownedActiveLock.token !== parsedHeaders.token) { return { ok: false, status: 423, error: 'Valid lock token is required for this mutation', code: 'record_locked', lock: this.toLockView(ownedActiveLock, false, activeLocks), } } } return { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, shouldReleaseOnSuccess, lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, latestActionLogId: latest?.id ?? null, } } const existingConflict = parsedHeaders.conflictId ? await this.findConflictById(parsedHeaders.conflictId, input) : null if (existingConflict) { const canResolveExistingConflict = existingConflict.status === 'pending' && existingConflict.conflictActorUserId === input.userId if (parsedHeaders.resolution === 'accept_mine' || parsedHeaders.resolution === 'merged') { const isAlreadyResolvedByRequester = existingConflict.conflictActorUserId === input.userId && existingConflict.status !== 'pending' && existingConflict.resolution === parsedHeaders.resolution if (!canResolveExistingConflict && !isAlreadyResolvedByRequester) { return { ok: false, status: 409, error: 'Record conflict requires resolution before saving', code: 'record_lock_conflict', lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, conflict: await this.toConflictPayload(existingConflict, input.mutationPayload ?? null, settings.allowIncomingOverride, canOverrideIncoming), } } if (!canOverrideIncoming) { return { ok: false, status: 409, error: 'Record conflict requires resolution before saving', code: 'record_lock_conflict', lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, conflict: await this.toConflictPayload(existingConflict, input.mutationPayload ?? null, settings.allowIncomingOverride, canOverrideIncoming), } } if (canResolveExistingConflict) { await this.resolveConflict(existingConflict, parsedHeaders.resolution, input.userId) } } else { return { ok: false, status: 409, error: 'Record conflict requires resolution before saving', code: 'record_lock_conflict', lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, conflict: await this.toConflictPayload(existingConflict, input.mutationPayload ?? null, settings.allowIncomingOverride, canOverrideIncoming), } } } if (!existingConflict) { const baseActionLogId = parsedHeaders.baseLogId ?? (ownedActiveLock ? ownedActiveLock.baseActionLogId : null) const hasConflictingBaseLog = Boolean( latest?.id && baseActionLogId && latest.id !== baseActionLogId ) const hasConflictingWriteAfterLockStart = Boolean( latest?.id && !baseActionLogId && ownedActiveLock && latest.createdAt instanceof Date && ownedActiveLock.lockedAt instanceof Date && latest.createdAt.getTime() > ownedActiveLock.lockedAt.getTime() && latest.actorUserId !== input.userId ) const isConflictingWrite = hasConflictingBaseLog || hasConflictingWriteAfterLockStart if (isConflictingWrite) { if (keepMineResolution && canOverrideIncoming) { const autoResolvedConflict = await this.createConflict({ scope: input, baseActionLogId, incomingActionLogId: latest?.id ?? null, conflictActorUserId: input.userId, incomingActorUserId: latest?.actorUserId ?? null, }) await this.resolveConflict(autoResolvedConflict, keepMineResolution, input.userId) return { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, shouldReleaseOnSuccess, lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, latestActionLogId: latest?.id ?? null, } } const conflict = await this.createConflict({ scope: input, baseActionLogId, incomingActionLogId: latest?.id ?? null, conflictActorUserId: input.userId, incomingActorUserId: latest?.actorUserId ?? null, }) return { ok: false, status: 409, error: 'Record conflict detected', code: 'record_lock_conflict', lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, conflict: await this.toConflictPayload(conflict, input.mutationPayload ?? null, settings.allowIncomingOverride, canOverrideIncoming), } } } return { ok: true, enabled: settings.enabled, resourceEnabled: true, strategy: settings.strategy, shouldReleaseOnSuccess, lock: ownedActiveLock ? this.toLockView(ownedActiveLock, false, activeLocks) : null, latestActionLogId: latest?.id ?? null, } } async releaseAfterMutation(input: RecordLockReleaseInput): Promise { const releaseResult = await this.release({ ...input, reason: input.reason ?? 'saved', }) if (!releaseResult.released) return } async emitIncomingChangesNotificationAfterMutation(input: { tenantId: string organizationId?: string | null userId: string resourceKind: string resourceId: string method: 'PUT' | 'DELETE' }): Promise { if (input.method !== 'PUT') return const settings = await this.getSettings() if (!settings.notifyOnConflict || !isRecordLockingEnabledForResource(settings, input.resourceKind)) return const now = new Date() let activeLocks = await this.findActiveLocks(input, now) if (!activeLocks.length) { const fallbackWhere: FilterQuery = { tenantId: input.tenantId, deletedAt: null, resourceKind: input.resourceKind, resourceId: input.resourceId, status: ACTIVE_LOCK_STATUS, } const fallbackLocks = await this.em.find(RecordLock, fallbackWhere, { orderBy: { updatedAt: 'desc' } }) activeLocks = Array.isArray(fallbackLocks) ? fallbackLocks : [] } const recipientUserIds = new Set() for (const lock of activeLocks) { if (lock.lockedByUserId !== input.userId) { recipientUserIds.add(lock.lockedByUserId) } } if (!recipientUserIds.size) { const fallbackWindowMs = Math.max((settings.timeoutSeconds ?? 300) * 1000, 60_000) const fallbackSince = new Date(now.getTime() - fallbackWindowMs) const recentLocks = await this.em.find(RecordLock, { tenantId: input.tenantId, deletedAt: null, resourceKind: input.resourceKind, resourceId: input.resourceId, updatedAt: { $gte: fallbackSince }, }, { orderBy: { updatedAt: 'desc' }, limit: 50 }) for (const lock of (Array.isArray(recentLocks) ? recentLocks : [])) { if (lock.lockedByUserId !== input.userId) { recipientUserIds.add(lock.lockedByUserId) } } } if (!recipientUserIds.size) return let latest = await this.findLatestActionLog(input) if (!latest) { latest = await this.findLatestActionLog({ tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, }) } let actorLog = latest?.actorUserId === input.userId ? latest : await this.findLatestActionLogByActor(input, input.userId) if (!actorLog) { actorLog = await this.findLatestActionLogByActor({ tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, }, input.userId) } const incomingLog = actorLog ?? latest const changedFields = incomingLog ? this.summarizeChangedFieldsFromActionLog(incomingLog) : '' const changedRows = this.buildIncomingChangeRowsFromActionLog(incomingLog) const changedRowsJson = changedRows.length ? JSON.stringify(changedRows) : '' await emitRecordLocksEvent('record_locks.incoming_changes.available', { resourceKind: input.resourceKind, resourceId: input.resourceId, tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId), incomingActorUserId: input.userId, incomingActionLogId: incomingLog?.id ?? null, recipientUserIds: Array.from(recipientUserIds), changedFields: changedFields || '-', changedRowsJson, }) } async emitRecordDeletedNotificationAfterMutation(input: { tenantId: string organizationId?: string | null userId: string resourceKind: string resourceId: string method: 'PUT' | 'DELETE' }): Promise { if (input.method !== 'DELETE') return const settings = await this.getSettings() if (!isRecordLockingEnabledForResource(settings, input.resourceKind)) return const now = new Date() let activeLocks = await this.findActiveLocks(input, now) if (!activeLocks.length) { const fallbackWhere: FilterQuery = { tenantId: input.tenantId, deletedAt: null, resourceKind: input.resourceKind, resourceId: input.resourceId, status: ACTIVE_LOCK_STATUS, } const fallbackLocks = await this.em.find(RecordLock, fallbackWhere, { orderBy: { updatedAt: 'desc' } }) activeLocks = Array.isArray(fallbackLocks) ? fallbackLocks : [] } const recipientUserIds = new Set() for (const lock of activeLocks) { if (lock.lockedByUserId !== input.userId) { recipientUserIds.add(lock.lockedByUserId) } } if (!recipientUserIds.size) { const fallbackWindowMs = Math.max((settings.timeoutSeconds ?? 300) * 1000, 60_000) const fallbackSince = new Date(now.getTime() - fallbackWindowMs) const recentLocks = await this.em.find(RecordLock, { tenantId: input.tenantId, deletedAt: null, resourceKind: input.resourceKind, resourceId: input.resourceId, updatedAt: { $gte: fallbackSince }, }, { orderBy: { updatedAt: 'desc' }, limit: 50 }) for (const lock of (Array.isArray(recentLocks) ? recentLocks : [])) { if (lock.lockedByUserId !== input.userId) { recipientUserIds.add(lock.lockedByUserId) } } } if (!recipientUserIds.size) return await emitRecordLocksEvent('record_locks.record.deleted', { resourceKind: input.resourceKind, resourceId: input.resourceId, tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId), deletedByUserId: input.userId, recipientUserIds: Array.from(recipientUserIds), }) } async resolveConflictById(input: { conflictId: string tenantId: string organizationId?: string | null userId: string resolution: 'accept_incoming' | 'accept_mine' | 'merged' }): Promise { const settings = await this.getSettings() const canOverrideIncoming = await this.canUserOverrideIncoming(input, settings) const conflict = await this.em.findOne(RecordLockConflict, { id: input.conflictId, tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId), deletedAt: null, }) if (!conflict || conflict.status !== 'pending' || conflict.conflictActorUserId !== input.userId) { return false } if ((input.resolution === 'accept_mine' || input.resolution === 'merged') && !canOverrideIncoming) { return false } await this.resolveConflict(conflict, input.resolution, input.userId) return true } private async canUserOverrideIncoming( input: Pick, settings: RecordLockSettings, ): Promise { if (!settings.allowIncomingOverride) return false if (!this.rbacService) return false try { return await this.rbacService.userHasAllFeatures( input.userId, ['record_locks.override_incoming'], { tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId), }, ) } catch { return false } } private async canUserForceRelease( input: Pick, settings: RecordLockSettings, ): Promise { if (!settings.allowForceUnlock) return false if (!this.rbacService) return false try { return await this.rbacService.userHasAllFeatures( input.userId, ['record_locks.force_release'], { tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId), }, ) } catch { return false } } private pruneLockCleanupState(now: number): void { for (const [tenantId, state] of lockCleanupStateByTenant.entries()) { if (!state.inFlight && now - state.lastSeenAt > LOCK_CLEANUP_STATE_TTL_MS) { lockCleanupStateByTenant.delete(tenantId) } } if (lockCleanupStateByTenant.size <= LOCK_CLEANUP_STATE_MAX_ENTRIES) return const removable = Array.from(lockCleanupStateByTenant.entries()) .filter(([, state]) => !state.inFlight) .sort((left, right) => left[1].lastSeenAt - right[1].lastSeenAt) const overflow = lockCleanupStateByTenant.size - LOCK_CLEANUP_STATE_MAX_ENTRIES for (const [tenantId] of removable.slice(0, Math.max(0, overflow))) { lockCleanupStateByTenant.delete(tenantId) } } private scheduleCleanup(tenantId: string): void { const now = Date.now() this.pruneLockCleanupState(now) const state = lockCleanupStateByTenant.get(tenantId) ?? { lastRunAt: 0, inFlight: false, lastSeenAt: now } state.lastSeenAt = now lockCleanupStateByTenant.set(tenantId, state) if (state.inFlight) return if (now - state.lastRunAt < LOCK_CLEANUP_INTERVAL_MS) return state.inFlight = true state.lastRunAt = now lockCleanupStateByTenant.set(tenantId, state) void this.cleanupHistoricalRecords(tenantId).finally(() => { const current = lockCleanupStateByTenant.get(tenantId) if (!current) return current.inFlight = false lockCleanupStateByTenant.set(tenantId, current) }) } private async cleanupHistoricalRecords(tenantId: string): Promise { try { const db = getKysely(this.em) const now = Date.now() const lockCutoff = new Date(now - LOCK_RETENTION_MS) const resolvedConflictCutoff = new Date(now - RESOLVED_CONFLICT_RETENTION_MS) const pendingConflictCutoff = new Date(now - PENDING_CONFLICT_RETENTION_MS) const deletedAt = new Date(now) await db .updateTable('record_locks' as any) .set({ deleted_at: deletedAt, updated_at: deletedAt, } as any) .where('tenant_id' as any, '=', tenantId) .where('deleted_at' as any, 'is', null as any) .where('status' as any, '!=', ACTIVE_LOCK_STATUS) .where('updated_at' as any, '<', lockCutoff) .execute() await db .updateTable('record_lock_conflicts' as any) .set({ deleted_at: deletedAt, updated_at: deletedAt, } as any) .where('tenant_id' as any, '=', tenantId) .where('deleted_at' as any, 'is', null as any) .where((eb: any) => eb.or([ eb.and([ eb('status' as any, '=', 'pending'), eb('created_at' as any, '<', pendingConflictCutoff), ]), eb.and([ eb('status' as any, '!=', 'pending'), eb('updated_at' as any, '<', resolvedConflictCutoff), ]), ])) .execute() } catch { // Best-effort cleanup must never fail lock workflows. } } private normalizeMutationHeaders(headers: Partial): Partial { const parsed = recordLockMutationHeaderSchema.partial().safeParse(headers) if (!parsed.success) return {} return parsed.data } private buildScopeWhere(scope: Pick): { tenantId: string deletedAt: null organizationId?: string | null } { const where: { tenantId: string deletedAt: null organizationId?: string | null } = { tenantId: scope.tenantId, deletedAt: null, } if (scope.organizationId !== undefined) { where.organizationId = normalizeScopeOrganization(scope.organizationId) } return where } private async findActiveLocks( input: Pick & RecordLockResource, now: Date, em: EntityManager = this.em, ): Promise { const legacyFinder = (this as unknown as { findActiveLock?: (args: Pick & RecordLockResource, at: Date) => Promise }).findActiveLock if (typeof legacyFinder === 'function') { const legacyResult = await legacyFinder(input, now) return legacyResult ? [legacyResult] : [] } const where: FilterQuery = { ...this.buildScopeWhere(input), resourceKind: input.resourceKind, resourceId: input.resourceId, status: ACTIVE_LOCK_STATUS, } const locks = await em.find(RecordLock, where, { orderBy: { updatedAt: 'desc' } }) if (!Array.isArray(locks) || !locks.length) return [] let dirty = false const active: RecordLock[] = [] const expiredLocks: RecordLock[] = [] for (const lock of locks) { if (lock.expiresAt <= now) { this.markLockReleased(lock, { status: 'expired', reason: 'expired', releasedByUserId: lock.lockedByUserId, now, }) dirty = true expiredLocks.push(lock) continue } active.push(lock) } if (dirty) await em.flush() if (expiredLocks.length) { const recipientUserIds = active.map((lock) => lock.lockedByUserId) for (const expiredLock of expiredLocks) { await emitRecordLocksEvent('record_locks.participant.left', { lockId: expiredLock.id, resourceKind: expiredLock.resourceKind, resourceId: expiredLock.resourceId, tenantId: expiredLock.tenantId, organizationId: expiredLock.organizationId, leftUserId: expiredLock.lockedByUserId, reason: 'expired', recipientUserIds, activeParticipantCount: active.length, }) } } return active } private async countActiveLocksForUser( input: Pick, now: Date, em: EntityManager = this.em, ): Promise { const where: FilterQuery = { ...this.buildScopeWhere(input), lockedByUserId: input.userId, status: ACTIVE_LOCK_STATUS, expiresAt: { $gt: now }, } return em.count(RecordLock, where) } private async lockUserQuotaScope( em: EntityManager, input: Pick, ): Promise { const lockKey = [ 'record_locks', 'quota', input.tenantId, normalizeScopeOrganization(input.organizationId) ?? 'global', input.userId, ].join(':') await em.getConnection().execute('select pg_advisory_xact_lock(hashtext(?))', [lockKey]) } private async findOwnedLockByToken( input: Pick & RecordLockResource & { token?: string }, ): Promise { if (!input.token) return null const where: FilterQuery = { ...this.buildScopeWhere(input), resourceKind: input.resourceKind, resourceId: input.resourceId, token: input.token, lockedByUserId: input.userId, status: ACTIVE_LOCK_STATUS, } return this.em.findOne(RecordLock, where) } private async findOwnedActiveLock( input: Pick & RecordLockResource, em: EntityManager = this.em, ): Promise { const where: FilterQuery = { ...this.buildScopeWhere(input), resourceKind: input.resourceKind, resourceId: input.resourceId, lockedByUserId: input.userId, status: ACTIVE_LOCK_STATUS, } return em.findOne(RecordLock, where) } private async hasRecentSavedRelease(input: { tenantId: string organizationId?: string | null userId: string resourceKind: string resourceId: string now: Date }): Promise { const cutoff = new Date(input.now.getTime() - PARTICIPANT_REJOIN_AFTER_SAVE_SUPPRESS_MS) const where: FilterQuery = { tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, lockedByUserId: input.userId, status: 'released', releaseReason: 'saved', deletedAt: null, releasedAt: { $gte: cutoff }, } if (input.organizationId !== undefined) { where.organizationId = normalizeScopeOrganization(input.organizationId) } const scoped = await this.em.findOne(RecordLock, where, { orderBy: { releasedAt: 'desc' } }) if (scoped) return true if (input.organizationId === undefined) return false return Boolean(await this.em.findOne(RecordLock, { tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, lockedByUserId: input.userId, status: 'released', releaseReason: 'saved', deletedAt: null, releasedAt: { $gte: cutoff }, }, { orderBy: { releasedAt: 'desc' } })) } private markLockReleased( lock: RecordLock, params: { status: RecordLockStatus reason: RecordLockReleaseReason releasedByUserId: string now: Date }, ) { lock.status = params.status lock.releaseReason = params.reason lock.releasedByUserId = params.releasedByUserId lock.releasedAt = params.now lock.updatedAt = params.now } private async findLatestActionLog( input: Pick & RecordLockResource, ): Promise { const where: FilterQuery = { tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, deletedAt: null, } if (input.organizationId !== undefined) { where.organizationId = normalizeScopeOrganization(input.organizationId) } return normalizeActionLogPayload(await findOneWithDecryption( this.em, ActionLog, where, { orderBy: { createdAt: 'desc' } }, { tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId) }, )) } private async findLatestActionLogWithScopeFallback( input: Pick & RecordLockResource, ): Promise { const scoped = await this.findLatestActionLog(input) if (scoped) return scoped if (input.organizationId !== null) return null return this.findLatestActionLog({ tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, }) } private async findLatestActionLogByActor( input: Pick & RecordLockResource, actorUserId: string, ): Promise { const where: FilterQuery = { tenantId: input.tenantId, resourceKind: input.resourceKind, resourceId: input.resourceId, actorUserId, deletedAt: null, } if (input.organizationId !== undefined) { where.organizationId = normalizeScopeOrganization(input.organizationId) } return normalizeActionLogPayload(await findOneWithDecryption( this.em, ActionLog, where, { orderBy: { createdAt: 'desc' } }, { tenantId: input.tenantId, organizationId: normalizeScopeOrganization(input.organizationId) }, )) } private summarizeChangedFieldsFromActionLog(log: ActionLog | null): string { if (!log) return '' const changesJson = readActionLogChangesJson(log) if (changesJson) { const fromChanges = Object.keys(changesJson) .filter((field) => !shouldSkipConflictField(field)) .slice(0, 12) .map(formatChangedFieldLabel) .join(', ') if (fromChanges) return fromChanges } const before = readJsonRecordValue(log.snapshotBefore) const after = readJsonRecordValue(log.snapshotAfter) if (!before || !after) return '' const diffPaths = new Set() this.collectSnapshotDiffPaths(before, after, null, diffPaths, new Set()) return Array.from(diffPaths) .filter((field) => !shouldSkipConflictField(field)) .sort((left, right) => left.localeCompare(right)) .slice(0, 12) .map(formatChangedFieldLabel) .join(', ') } private buildIncomingChangeRowsFromActionLog(log: ActionLog | null): Array<{ field: string incoming: string current: string }> { const changesJson = readActionLogChangesJson(log) if (!changesJson) return [] const rows: Array<{ field: string; incoming: string; current: string }> = [] for (const [rawField, rawChange] of Object.entries(changesJson)) { if (rows.length >= 12) break if (shouldSkipConflictField(rawField)) continue const change = isRecordValue(rawChange) ? rawChange : {} const incoming = Object.prototype.hasOwnProperty.call(change, 'to') ? formatNotificationValue(change.to) : '-' const current = Object.prototype.hasOwnProperty.call(change, 'from') ? formatNotificationValue(change.from) : '-' rows.push({ field: formatChangedFieldLabel(rawField), incoming, current, }) } return rows } private toParticipantView(lock: RecordLock): RecordLockParticipantView { return { userId: lock.lockedByUserId, lockedByIp: lock.lockedByIp ?? null, lockedAt: normalizeDate(lock.lockedAt), lastHeartbeatAt: normalizeDate(lock.lastHeartbeatAt), expiresAt: normalizeDate(lock.expiresAt), } } private sortLocksByJoinOrder(locks: RecordLock[]): RecordLock[] { return [...locks].sort((left, right) => { const leftLockedAt = left.lockedAt instanceof Date ? left.lockedAt.getTime() : 0 const rightLockedAt = right.lockedAt instanceof Date ? right.lockedAt.getTime() : 0 if (leftLockedAt !== rightLockedAt) return leftLockedAt - rightLockedAt const leftCreatedAt = left.createdAt instanceof Date ? left.createdAt.getTime() : 0 const rightCreatedAt = right.createdAt instanceof Date ? right.createdAt.getTime() : 0 if (leftCreatedAt !== rightCreatedAt) return leftCreatedAt - rightCreatedAt return left.id.localeCompare(right.id) }) } private toLockView(lock: RecordLock, includeToken: boolean, activeLocks: RecordLock[] = [lock]): RecordLockView { const participants = activeLocks.map((item) => this.toParticipantView(item)) return { id: lock.id, resourceKind: lock.resourceKind, resourceId: lock.resourceId, token: includeToken ? lock.token : null, strategy: lock.strategy, status: lock.status, lockedByUserId: lock.lockedByUserId, lockedByIp: lock.lockedByIp ?? null, baseActionLogId: lock.baseActionLogId, lockedAt: normalizeDate(lock.lockedAt), lastHeartbeatAt: normalizeDate(lock.lastHeartbeatAt), expiresAt: normalizeDate(lock.expiresAt), participants, activeParticipantCount: participants.length, } } private async createConflict(input: { scope: Pick & RecordLockResource baseActionLogId: string | null incomingActionLogId: string | null conflictActorUserId: string incomingActorUserId: string | null }): Promise { const dedupeKey = [ 'record_locks', 'conflict', input.scope.tenantId, normalizeScopeOrganization(input.scope.organizationId) ?? 'global', input.scope.resourceKind, input.scope.resourceId, input.conflictActorUserId, input.baseActionLogId ?? 'none', input.incomingActionLogId ?? 'none', ].join(':') const result = await this.em.transactional(async (tx) => { try { const db = getKysely(tx as EntityManager) await sql`select pg_advisory_xact_lock(hashtext(${dedupeKey}))`.execute(db) } catch { // Best-effort lock; fallback to find-first behavior below. } const existing = await this.findPendingConflictByFingerprint(tx as EntityManager, input) if (existing) { return { conflict: existing, created: false as const } } const conflict = tx.create(RecordLockConflict, { resourceKind: input.scope.resourceKind, resourceId: input.scope.resourceId, status: 'pending', resolution: null, baseActionLogId: input.baseActionLogId, incomingActionLogId: input.incomingActionLogId, conflictActorUserId: input.conflictActorUserId, incomingActorUserId: input.incomingActorUserId, tenantId: input.scope.tenantId, organizationId: normalizeScopeOrganization(input.scope.organizationId), }) tx.persist(conflict) await tx.flush() return { conflict, created: true as const } }) if (result.created) { await emitRecordLocksEvent('record_locks.conflict.detected', { conflictId: result.conflict.id, resourceKind: result.conflict.resourceKind, resourceId: result.conflict.resourceId, tenantId: result.conflict.tenantId, organizationId: result.conflict.organizationId, conflictActorUserId: result.conflict.conflictActorUserId, incomingActorUserId: result.conflict.incomingActorUserId, baseActionLogId: result.conflict.baseActionLogId, incomingActionLogId: result.conflict.incomingActionLogId, }) } return result.conflict } private findPendingConflictByFingerprint( em: EntityManager, input: { scope: Pick & RecordLockResource baseActionLogId: string | null incomingActionLogId: string | null conflictActorUserId: string }, ): Promise { return em.findOne(RecordLockConflict, { tenantId: input.scope.tenantId, organizationId: normalizeScopeOrganization(input.scope.organizationId), resourceKind: input.scope.resourceKind, resourceId: input.scope.resourceId, conflictActorUserId: input.conflictActorUserId, status: 'pending', baseActionLogId: input.baseActionLogId, incomingActionLogId: input.incomingActionLogId, deletedAt: null, }, { orderBy: { createdAt: 'desc' } }) } private async resolveConflict( conflict: RecordLockConflict, resolution: 'accept_incoming' | Extract, resolvedByUserId: string, ): Promise { const now = new Date() const resolutionMap: Record<'accept_incoming' | Extract, { status: RecordLockConflictStatus resolution: RecordLockConflictResolution }> = { accept_incoming: { status: 'resolved_accept_incoming', resolution: 'accept_incoming' }, accept_mine: { status: 'resolved_accept_mine', resolution: 'accept_mine' }, merged: { status: 'resolved_merged', resolution: 'merged' }, } const target = resolutionMap[resolution] conflict.status = target.status conflict.resolution = target.resolution conflict.resolvedByUserId = resolvedByUserId conflict.resolvedAt = now conflict.updatedAt = now await this.em.flush() await emitRecordLocksEvent('record_locks.conflict.resolved', { conflictId: conflict.id, resourceKind: conflict.resourceKind, resourceId: conflict.resourceId, tenantId: conflict.tenantId, organizationId: conflict.organizationId, conflictActorUserId: conflict.conflictActorUserId, incomingActorUserId: conflict.incomingActorUserId, resolution: conflict.resolution, resolvedByUserId, }) } private async findConflictById( conflictId: string, scope: Pick & RecordLockResource, ): Promise { const where: FilterQuery = { id: conflictId, tenantId: scope.tenantId, resourceKind: scope.resourceKind, resourceId: scope.resourceId, deletedAt: null, } if (scope.organizationId !== undefined) { where.organizationId = normalizeScopeOrganization(scope.organizationId) } const scoped = await this.em.findOne(RecordLockConflict, where) if (scoped || scope.organizationId === undefined) return scoped return this.em.findOne(RecordLockConflict, { id: conflictId, tenantId: scope.tenantId, resourceKind: scope.resourceKind, resourceId: scope.resourceId, deletedAt: null, }) } private async findActionLogById( logId: string | null, scope: Pick & RecordLockResource, ): Promise { if (!logId) return null let resolved = this.actionLogService ? await this.actionLogService.findById(logId) : null if (!resolved) { resolved = await findOneWithDecryption( this.em, ActionLog, { id: logId, deletedAt: null }, undefined, { tenantId: scope.tenantId, organizationId: normalizeScopeOrganization(scope.organizationId) }, ) } resolved = normalizeActionLogPayload(resolved) if (!resolved || resolved.deletedAt) return null if (resolved.tenantId !== scope.tenantId) return null if (scope.organizationId !== undefined) { const expectedOrganizationId = normalizeScopeOrganization(scope.organizationId) if (normalizeScopeOrganization(resolved.organizationId) !== expectedOrganizationId) return null } if (resolved.resourceKind !== scope.resourceKind || resolved.resourceId !== scope.resourceId) { return null } return resolved } private collectSnapshotDiffPaths( before: unknown, after: unknown, pathPrefix: string | null, output: Set, seen: Set, ): void { if (valuesEqual(before, after)) return const beforeRecord = isRecordValue(before) ? before : null const afterRecord = isRecordValue(after) ? after : null if (!beforeRecord || !afterRecord) { if (pathPrefix) output.add(pathPrefix) return } if (seen.has(beforeRecord) || seen.has(afterRecord)) { if (pathPrefix) output.add(pathPrefix) return } seen.add(beforeRecord) seen.add(afterRecord) const keys = new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)]) for (const key of keys) { if (SKIPPED_CONFLICT_FIELDS.has(key)) continue const nextPath = pathPrefix ? `${pathPrefix}.${key}` : key this.collectSnapshotDiffPaths(beforeRecord[key], afterRecord[key], nextPath, output, seen) } } private async buildConflictChanges( conflict: RecordLockConflict, mutationPayload: Record | null, ): Promise { const scope = { tenantId: conflict.tenantId, organizationId: conflict.organizationId, resourceKind: conflict.resourceKind, resourceId: conflict.resourceId, } const baseLog = await this.findActionLogById(conflict.baseActionLogId, scope) const incomingLog = await this.findActionLogById(conflict.incomingActionLogId, scope) const baseSnapshot = readJsonRecordValue(baseLog?.snapshotAfter ?? null) const incomingBeforeSnapshot = readJsonRecordValue(incomingLog?.snapshotBefore ?? null) const incomingAfterSnapshot = readJsonRecordValue(incomingLog?.snapshotAfter ?? null) const fallbackBaseSnapshot = baseSnapshot ?? incomingBeforeSnapshot const changeMap = new Map() const incomingChanges = readActionLogChangesJson(incomingLog) if (incomingChanges) { for (const [fieldPathRaw, rawChange] of Object.entries(incomingChanges)) { const fieldPath = fieldPathRaw.trim() if (shouldSkipConflictField(fieldPath)) continue const changeRecord = isRecordValue(rawChange) ? rawChange : null const fromValue = changeRecord && Object.prototype.hasOwnProperty.call(changeRecord, 'from') ? changeRecord.from : readPathValueLoose(fallbackBaseSnapshot, fieldPath) const toValue = changeRecord && Object.prototype.hasOwnProperty.call(changeRecord, 'to') ? changeRecord.to : readPathValueLoose(incomingAfterSnapshot, fieldPath) changeMap.set(fieldPath, { baseValue: fromValue === MISSING_CONFLICT_VALUE ? null : normalizeConflictValue(fromValue), incomingValue: toValue === MISSING_CONFLICT_VALUE ? null : normalizeConflictValue(toValue), }) } } if (!changeMap.size && fallbackBaseSnapshot && incomingAfterSnapshot) { const diffPaths = new Set() this.collectSnapshotDiffPaths( fallbackBaseSnapshot, incomingAfterSnapshot, null, diffPaths, new Set(), ) for (const fieldPath of diffPaths) { if (shouldSkipConflictField(fieldPath)) continue const fromValue = readPathValueLoose(fallbackBaseSnapshot, fieldPath) const toValue = readPathValueLoose(incomingAfterSnapshot, fieldPath) changeMap.set(fieldPath, { baseValue: fromValue === MISSING_CONFLICT_VALUE ? null : normalizeConflictValue(fromValue), incomingValue: toValue === MISSING_CONFLICT_VALUE ? null : normalizeConflictValue(toValue), }) } } if (!changeMap.size && mutationPayload && incomingAfterSnapshot) { for (const fieldPath of Object.keys(mutationPayload)) { if (shouldSkipConflictField(fieldPath)) continue const mineValue = readPathValueLoose(mutationPayload, fieldPath) const incomingValue = readPathValueLoose(incomingAfterSnapshot, fieldPath) if (mineValue === MISSING_CONFLICT_VALUE || incomingValue === MISSING_CONFLICT_VALUE) continue if (valuesEqual(mineValue, incomingValue)) continue const baseValue = readPathValueLoose(fallbackBaseSnapshot, fieldPath) changeMap.set(fieldPath, { baseValue: baseValue === MISSING_CONFLICT_VALUE ? null : normalizeConflictValue(baseValue), incomingValue: normalizeConflictValue(incomingValue), }) } } if (!changeMap.size) return [] const allFields = Array.from(changeMap.keys()) const preferredFields = mutationPayload ? allFields.filter((fieldPath) => { const mineValue = readPathValueLoose(mutationPayload, fieldPath) if (mineValue === MISSING_CONFLICT_VALUE) return false const incomingValue = changeMap.get(fieldPath)?.incomingValue return !valuesEqual(mineValue, incomingValue) }) : [] const selectedFields = (preferredFields.length ? preferredFields : allFields) .filter((fieldPath) => !shouldSkipConflictField(fieldPath)) .sort((left, right) => left.localeCompare(right)) .slice(0, 25) return selectedFields.map((fieldPath) => { const entry = changeMap.get(fieldPath) ?? { baseValue: null, incomingValue: null } const mineValueRaw = mutationPayload ? readPathValueLoose(mutationPayload, fieldPath) : MISSING_CONFLICT_VALUE const mineValue = mineValueRaw === MISSING_CONFLICT_VALUE ? entry.baseValue : normalizeConflictValue(mineValueRaw) return { field: fieldPath, displayValue: normalizeConflictValue(entry.baseValue), baseValue: normalizeConflictValue(entry.baseValue), incomingValue: normalizeConflictValue(entry.incomingValue), mineValue: normalizeConflictValue(mineValue), } }) } private async toConflictPayload( conflict: RecordLockConflict, mutationPayload: Record | null, allowIncomingOverride: boolean, canOverrideIncoming: boolean, ): Promise { const changes = await this.buildConflictChanges(conflict, mutationPayload) return { id: conflict.id, resourceKind: conflict.resourceKind, resourceId: conflict.resourceId, baseActionLogId: conflict.baseActionLogId, incomingActionLogId: conflict.incomingActionLogId, allowIncomingOverride, canOverrideIncoming, resolutionOptions: canOverrideIncoming ? ['accept_mine'] : [], changes, } } } export function createRecordLockService(deps: RecordLockServiceDeps): RecordLockService { return new RecordLockService(deps) }