@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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/enterprise",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
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.
|
|
69
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
1639
|
+
return em.findOne(RecordLock, where)
|
|
1473
1640
|
}
|
|
1474
1641
|
|
|
1475
1642
|
private async hasRecentSavedRelease(input: {
|