@open-mercato/enterprise 0.6.6-develop.6472.1.1673e7e66b → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/enterprise",
3
- "version": "0.6.6-develop.6472.1.1673e7e66b",
3
+ "version": "0.6.6-develop.6480.1.309f47ca1c",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@open-mercato/core": "0.6.6-develop.6472.1.1673e7e66b",
69
- "@open-mercato/ui": "0.6.6-develop.6472.1.1673e7e66b",
68
+ "@open-mercato/core": "0.6.6-develop.6480.1.309f47ca1c",
69
+ "@open-mercato/ui": "0.6.6-develop.6480.1.309f47ca1c",
70
70
  "@simplewebauthn/browser": "^13.3.0",
71
71
  "@simplewebauthn/server": "^13.3.2",
72
72
  "@simplewebauthn/types": "^12.0.0",
@@ -76,12 +76,12 @@
76
76
  "qrcode": "^1.5.4"
77
77
  },
78
78
  "peerDependencies": {
79
- "@open-mercato/shared": "0.6.6-develop.6472.1.1673e7e66b",
79
+ "@open-mercato/shared": "0.6.6-develop.6480.1.309f47ca1c",
80
80
  "react": "^19.0.0",
81
81
  "react-dom": "^19.0.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@open-mercato/shared": "0.6.6-develop.6472.1.1673e7e66b",
84
+ "@open-mercato/shared": "0.6.6-develop.6480.1.309f47ca1c",
85
85
  "@types/jest": "^30.0.0",
86
86
  "@types/react": "^19.2.17",
87
87
  "@types/react-dom": "^19.2.3",
@@ -191,6 +191,7 @@ export const openApi: OpenApiRouteDoc = {
191
191
  { status: 400, description: 'Invalid payload', schema: recordLockErrorSchema },
192
192
  { status: 401, description: 'Unauthorized', schema: recordLockErrorSchema },
193
193
  { status: 423, description: 'Record locked by another user', schema: recordLockErrorSchema },
194
+ { status: 429, description: 'Per-user active lock quota exceeded', schema: recordLockErrorSchema },
194
195
  ],
195
196
  },
196
197
  },
@@ -7,6 +7,7 @@ export const recordLockSettingsSchema = z.object({
7
7
  strategy: recordLockStrategySchema.default('optimistic'),
8
8
  timeoutSeconds: z.number().int().min(30).max(3600).default(300),
9
9
  heartbeatSeconds: z.number().int().min(5).max(300).default(30),
10
+ maxActiveLocksPerUser: z.number().int().min(1).max(500).default(50),
10
11
  enabledResources: z.array(z.string().trim().min(1)).default([]),
11
12
  allowForceUnlock: z.boolean().default(true),
12
13
  allowIncomingOverride: z.boolean().default(true),
@@ -21,6 +22,7 @@ export const DEFAULT_RECORD_LOCK_SETTINGS: RecordLockSettings = {
21
22
  strategy: 'optimistic',
22
23
  timeoutSeconds: 300,
23
24
  heartbeatSeconds: 30,
25
+ maxActiveLocksPerUser: 50,
24
26
  enabledResources: ['*'],
25
27
  allowForceUnlock: true,
26
28
  allowIncomingOverride: true,
@@ -143,7 +143,9 @@ export type RecordLockAcquireResult = {
143
143
  lock: RecordLockView | null
144
144
  }
145
145
 
146
- export type RecordLockAcquireFailure = RecordLockValidationFailure & {
146
+ export type RecordLockAcquireFailure = Omit<RecordLockValidationFailure, 'status' | 'code'> & {
147
+ status: RecordLockValidationFailure['status'] | 429
148
+ code: RecordLockValidationFailure['code'] | 'record_lock_quota_exceeded'
147
149
  allowForceUnlock: boolean
148
150
  }
149
151
 
@@ -592,26 +594,167 @@ export class RecordLockService {
592
594
  }
593
595
  }
594
596
 
595
- const lock = this.em.create(RecordLock, {
596
- resourceKind: input.resourceKind,
597
- resourceId: input.resourceId,
598
- token: randomUUID(),
599
- strategy: settings.strategy,
600
- status: ACTIVE_LOCK_STATUS,
601
- lockedByUserId: input.userId,
602
- lockedByIp: input.lockedByIp ?? null,
603
- baseActionLogId: latest?.id ?? null,
604
- lockedAt: now,
605
- lastHeartbeatAt: now,
606
- expiresAt: new Date(now.getTime() + settings.timeoutSeconds * 1000),
607
- tenantId: input.tenantId,
608
- organizationId: normalizeScopeOrganization(input.organizationId),
609
- })
610
-
611
- this.em.persist(lock)
612
- let createdNewLock = true
597
+ let activeAfterAcquire: RecordLock[] = []
598
+ let ownedAfterAcquire: RecordLock | null = null
599
+ let createdNewLock = false
613
600
  try {
614
- await this.em.flush()
601
+ const outcome = await this.em.transactional(async (tx) => {
602
+ const writeEm = tx as EntityManager
603
+ await this.lockUserQuotaScope(writeEm, input)
604
+
605
+ const activeLocksInTransaction = await this.findActiveLocks(input, now, writeEm)
606
+ const ownedLockInTransaction = activeLocksInTransaction.find((lock) => lock.lockedByUserId === input.userId) ?? null
607
+ const competingLockInTransaction = activeLocksInTransaction.find((lock) => lock.lockedByUserId !== input.userId) ?? null
608
+
609
+ if (settings.strategy === 'pessimistic' && !ownedLockInTransaction && competingLockInTransaction) {
610
+ return {
611
+ result: {
612
+ ok: false,
613
+ status: 423,
614
+ error: 'Record is currently locked by another user',
615
+ code: 'record_locked',
616
+ allowForceUnlock: settings.allowForceUnlock,
617
+ lock: this.toLockView(competingLockInTransaction, false, activeLocksInTransaction),
618
+ } satisfies RecordLockAcquireFailure,
619
+ contentionLock: competingLockInTransaction,
620
+ createdNewLock: false,
621
+ activeAfterAcquire: activeLocksInTransaction,
622
+ ownedAfterAcquire: null,
623
+ }
624
+ }
625
+
626
+ if (ownedLockInTransaction) {
627
+ ownedLockInTransaction.strategy = settings.strategy
628
+ ownedLockInTransaction.lockedByIp = input.lockedByIp ?? ownedLockInTransaction.lockedByIp ?? null
629
+ ownedLockInTransaction.lastHeartbeatAt = now
630
+ ownedLockInTransaction.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1000)
631
+ await writeEm.flush()
632
+
633
+ const renewedActiveLocks = await this.findActiveLocks(input, now, writeEm)
634
+ return {
635
+ result: {
636
+ ok: true,
637
+ enabled: settings.enabled,
638
+ resourceEnabled: true,
639
+ strategy: settings.strategy,
640
+ allowForceUnlock: settings.allowForceUnlock,
641
+ heartbeatSeconds: settings.heartbeatSeconds,
642
+ acquired: false,
643
+ latestActionLogId: latest?.id ?? null,
644
+ lock: this.toLockView(ownedLockInTransaction, true, renewedActiveLocks),
645
+ } satisfies RecordLockAcquireResult,
646
+ createdNewLock: false,
647
+ activeAfterAcquire: renewedActiveLocks,
648
+ ownedAfterAcquire: ownedLockInTransaction,
649
+ }
650
+ }
651
+
652
+ const activeLocksForUser = await this.countActiveLocksForUser(input, now, writeEm)
653
+ if (activeLocksForUser >= settings.maxActiveLocksPerUser) {
654
+ return {
655
+ result: {
656
+ ok: false,
657
+ status: 429,
658
+ error: 'Active record lock limit reached',
659
+ code: 'record_lock_quota_exceeded',
660
+ allowForceUnlock: false,
661
+ lock: null,
662
+ } satisfies RecordLockAcquireFailure,
663
+ createdNewLock: false,
664
+ activeAfterAcquire: activeLocksInTransaction,
665
+ ownedAfterAcquire: null,
666
+ }
667
+ }
668
+
669
+ const lock = writeEm.create(RecordLock, {
670
+ resourceKind: input.resourceKind,
671
+ resourceId: input.resourceId,
672
+ token: randomUUID(),
673
+ strategy: settings.strategy,
674
+ status: ACTIVE_LOCK_STATUS,
675
+ lockedByUserId: input.userId,
676
+ lockedByIp: input.lockedByIp ?? null,
677
+ baseActionLogId: latest?.id ?? null,
678
+ lockedAt: now,
679
+ lastHeartbeatAt: now,
680
+ expiresAt: new Date(now.getTime() + settings.timeoutSeconds * 1000),
681
+ tenantId: input.tenantId,
682
+ organizationId: normalizeScopeOrganization(input.organizationId),
683
+ })
684
+
685
+ writeEm.persist(lock)
686
+ await writeEm.flush()
687
+
688
+ const acquiredActiveLocks = await this.findActiveLocks(input, now, writeEm)
689
+ const acquiredOwnedLock = acquiredActiveLocks.find((item) => item.lockedByUserId === input.userId)
690
+ ?? await this.findOwnedActiveLock(input, writeEm)
691
+ ?? lock
692
+ ?? null
693
+
694
+ if (!acquiredOwnedLock) {
695
+ const fallbackLock = acquiredActiveLocks[0] ?? null
696
+ return {
697
+ result: {
698
+ ok: true,
699
+ enabled: settings.enabled,
700
+ resourceEnabled: true,
701
+ strategy: settings.strategy,
702
+ allowForceUnlock: settings.allowForceUnlock,
703
+ heartbeatSeconds: settings.heartbeatSeconds,
704
+ acquired: false,
705
+ latestActionLogId: latest?.id ?? null,
706
+ lock: fallbackLock ? this.toLockView(fallbackLock, false, acquiredActiveLocks) : null,
707
+ } satisfies RecordLockAcquireResult,
708
+ createdNewLock: false,
709
+ activeAfterAcquire: acquiredActiveLocks,
710
+ ownedAfterAcquire: null,
711
+ }
712
+ }
713
+
714
+ return {
715
+ result: {
716
+ ok: true,
717
+ enabled: settings.enabled,
718
+ resourceEnabled: true,
719
+ strategy: settings.strategy,
720
+ allowForceUnlock: settings.allowForceUnlock,
721
+ heartbeatSeconds: settings.heartbeatSeconds,
722
+ acquired: true,
723
+ latestActionLogId: latest?.id ?? null,
724
+ lock: this.toLockView(acquiredOwnedLock, true, acquiredActiveLocks),
725
+ } satisfies RecordLockAcquireResult,
726
+ createdNewLock: true,
727
+ activeAfterAcquire: acquiredActiveLocks,
728
+ ownedAfterAcquire: acquiredOwnedLock,
729
+ }
730
+ })
731
+
732
+ if (outcome.contentionLock && shouldEmitLockContentionEvent({
733
+ tenantId: outcome.contentionLock.tenantId,
734
+ organizationId: outcome.contentionLock.organizationId,
735
+ resourceKind: outcome.contentionLock.resourceKind,
736
+ resourceId: outcome.contentionLock.resourceId,
737
+ lockedByUserId: outcome.contentionLock.lockedByUserId,
738
+ attemptedByUserId: input.userId,
739
+ })) {
740
+ await emitRecordLocksEvent('record_locks.lock.contended', {
741
+ lockId: outcome.contentionLock.id,
742
+ resourceKind: outcome.contentionLock.resourceKind,
743
+ resourceId: outcome.contentionLock.resourceId,
744
+ tenantId: outcome.contentionLock.tenantId,
745
+ organizationId: outcome.contentionLock.organizationId,
746
+ lockedByUserId: outcome.contentionLock.lockedByUserId,
747
+ attemptedByUserId: input.userId,
748
+ })
749
+ }
750
+
751
+ activeAfterAcquire = outcome.activeAfterAcquire
752
+ ownedAfterAcquire = outcome.ownedAfterAcquire
753
+ createdNewLock = outcome.createdNewLock
754
+
755
+ if (!outcome.result.ok || !createdNewLock) {
756
+ return outcome.result
757
+ }
615
758
  } catch (error) {
616
759
  if (!isActiveLockScopeUniqueViolation(error)) throw error
617
760
  const clear = (this.em as { clear?: () => void }).clear
@@ -653,16 +796,8 @@ export class RecordLockService {
653
796
  existingOwned.lastHeartbeatAt = now
654
797
  existingOwned.expiresAt = new Date(now.getTime() + settings.timeoutSeconds * 1000)
655
798
  await this.em.flush()
656
- createdNewLock = false
657
- }
658
-
659
- const activeAfterAcquire = await this.findActiveLocks(input, now)
660
- const ownedAfterAcquire = activeAfterAcquire.find((item) => item.lockedByUserId === input.userId)
661
- ?? await this.findOwnedActiveLock(input)
662
- ?? lock
663
- ?? null
664
- if (!ownedAfterAcquire) {
665
- const fallbackLock = activeAfterAcquire[0] ?? null
799
+ activeAfterAcquire = await this.findActiveLocks(input, now)
800
+ ownedAfterAcquire = existingOwned
666
801
  return {
667
802
  ok: true,
668
803
  enabled: settings.enabled,
@@ -672,11 +807,11 @@ export class RecordLockService {
672
807
  heartbeatSeconds: settings.heartbeatSeconds,
673
808
  acquired: false,
674
809
  latestActionLogId: latest?.id ?? null,
675
- lock: fallbackLock ? this.toLockView(fallbackLock, false, activeAfterAcquire) : null,
810
+ lock: this.toLockView(existingOwned, true, activeAfterAcquire),
676
811
  }
677
812
  }
678
813
 
679
- if (createdNewLock) {
814
+ if (createdNewLock && ownedAfterAcquire) {
680
815
  await emitRecordLocksEvent('record_locks.lock.acquired', {
681
816
  lockId: ownedAfterAcquire.id,
682
817
  resourceKind: ownedAfterAcquire.resourceKind,
@@ -725,7 +860,7 @@ export class RecordLockService {
725
860
  heartbeatSeconds: settings.heartbeatSeconds,
726
861
  acquired: createdNewLock,
727
862
  latestActionLogId: latest?.id ?? null,
728
- lock: this.toLockView(ownedAfterAcquire, true, activeAfterAcquire),
863
+ lock: ownedAfterAcquire ? this.toLockView(ownedAfterAcquire, true, activeAfterAcquire) : null,
729
864
  }
730
865
  }
731
866
 
@@ -1383,6 +1518,7 @@ export class RecordLockService {
1383
1518
  private async findActiveLocks(
1384
1519
  input: Pick<RecordLockScope, 'tenantId' | 'organizationId'> & RecordLockResource,
1385
1520
  now: Date,
1521
+ em: EntityManager = this.em,
1386
1522
  ): Promise<RecordLock[]> {
1387
1523
  const legacyFinder = (this as unknown as {
1388
1524
  findActiveLock?: (args: Pick<RecordLockScope, 'tenantId' | 'organizationId'> & RecordLockResource, at: Date) => Promise<RecordLock | null>
@@ -1399,7 +1535,7 @@ export class RecordLockService {
1399
1535
  status: ACTIVE_LOCK_STATUS,
1400
1536
  }
1401
1537
 
1402
- const locks = await this.em.find(RecordLock, where, { orderBy: { updatedAt: 'desc' } })
1538
+ const locks = await em.find(RecordLock, where, { orderBy: { updatedAt: 'desc' } })
1403
1539
  if (!Array.isArray(locks) || !locks.length) return []
1404
1540
 
1405
1541
  let dirty = false
@@ -1422,7 +1558,7 @@ export class RecordLockService {
1422
1558
  active.push(lock)
1423
1559
  }
1424
1560
 
1425
- if (dirty) await this.em.flush()
1561
+ if (dirty) await em.flush()
1426
1562
  if (expiredLocks.length) {
1427
1563
  const recipientUserIds = active.map((lock) => lock.lockedByUserId)
1428
1564
  for (const expiredLock of expiredLocks) {
@@ -1442,6 +1578,36 @@ export class RecordLockService {
1442
1578
  return active
1443
1579
  }
1444
1580
 
1581
+ private async countActiveLocksForUser(
1582
+ input: Pick<RecordLockScope, 'tenantId' | 'organizationId' | 'userId'>,
1583
+ now: Date,
1584
+ em: EntityManager = this.em,
1585
+ ): Promise<number> {
1586
+ const where: FilterQuery<RecordLock> = {
1587
+ ...this.buildScopeWhere(input),
1588
+ lockedByUserId: input.userId,
1589
+ status: ACTIVE_LOCK_STATUS,
1590
+ expiresAt: { $gt: now },
1591
+ }
1592
+
1593
+ return em.count(RecordLock, where)
1594
+ }
1595
+
1596
+ private async lockUserQuotaScope(
1597
+ em: EntityManager,
1598
+ input: Pick<RecordLockScope, 'tenantId' | 'organizationId' | 'userId'>,
1599
+ ): Promise<void> {
1600
+ const lockKey = [
1601
+ 'record_locks',
1602
+ 'quota',
1603
+ input.tenantId,
1604
+ normalizeScopeOrganization(input.organizationId) ?? 'global',
1605
+ input.userId,
1606
+ ].join(':')
1607
+
1608
+ await em.getConnection().execute('select pg_advisory_xact_lock(hashtext(?))', [lockKey])
1609
+ }
1610
+
1445
1611
  private async findOwnedLockByToken(
1446
1612
  input: Pick<RecordLockScope, 'tenantId' | 'organizationId' | 'userId'> & RecordLockResource & { token?: string },
1447
1613
  ): Promise<RecordLock | null> {
@@ -1461,6 +1627,7 @@ export class RecordLockService {
1461
1627
 
1462
1628
  private async findOwnedActiveLock(
1463
1629
  input: Pick<RecordLockScope, 'tenantId' | 'organizationId' | 'userId'> & RecordLockResource,
1630
+ em: EntityManager = this.em,
1464
1631
  ): Promise<RecordLock | null> {
1465
1632
  const where: FilterQuery<RecordLock> = {
1466
1633
  ...this.buildScopeWhere(input),
@@ -1469,7 +1636,7 @@ export class RecordLockService {
1469
1636
  lockedByUserId: input.userId,
1470
1637
  status: ACTIVE_LOCK_STATUS,
1471
1638
  }
1472
- return this.em.findOne(RecordLock, where)
1639
+ return em.findOne(RecordLock, where)
1473
1640
  }
1474
1641
 
1475
1642
  private async hasRecentSavedRelease(input: {