@open-mercato/core 0.6.6-develop.6255.1.a6ee4a57c1 → 0.6.6-develop.6256.1.9fc16aedc4

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.
@@ -1,6 +1,11 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { CacheStrategy } from '@open-mercato/cache'
3
- import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
3
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+ import {
5
+ buildOptimisticLockConflictBody,
6
+ enforceCommandOptimisticLock,
7
+ enforceRecordGoneIsConflict,
8
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
4
9
  import { Perspective, RolePerspective } from '../data/entities'
5
10
  import type {
6
11
  PerspectiveSettings,
@@ -44,6 +49,50 @@ export type PerspectivesState = {
44
49
  rolePerspectives: ResolvedRolePerspective[]
45
50
  }
46
51
 
52
+ type ExpectedUpdatedAtById = Record<string, string | Date | null | undefined>
53
+
54
+ const PERSPECTIVE_RESOURCE_KIND = 'perspectives.perspective'
55
+ const ROLE_PERSPECTIVE_RESOURCE_KIND = 'perspectives.role_perspective'
56
+
57
+ function resolveRoleLockInput(
58
+ expectedUpdatedAtByRoleId: ExpectedUpdatedAtById | null | undefined,
59
+ roleId: string,
60
+ request: Request | Headers | null | undefined,
61
+ ): Pick<Parameters<typeof enforceCommandOptimisticLock>[0], 'expected' | 'request'> {
62
+ if (expectedUpdatedAtByRoleId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByRoleId, roleId)) {
63
+ return { expected: expectedUpdatedAtByRoleId[roleId] ?? null, request: null }
64
+ }
65
+ return { expected: undefined, request: request ?? null }
66
+ }
67
+
68
+ function resolveRoleRecordLockInput(
69
+ expectedUpdatedAtByPerspectiveId: ExpectedUpdatedAtById | null | undefined,
70
+ expectedUpdatedAtByRoleId: ExpectedUpdatedAtById | null | undefined,
71
+ record: RolePerspective,
72
+ request: Request | Headers | null | undefined,
73
+ ): Pick<Parameters<typeof enforceCommandOptimisticLock>[0], 'expected' | 'request'> {
74
+ if (expectedUpdatedAtByPerspectiveId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByPerspectiveId, record.id)) {
75
+ return { expected: expectedUpdatedAtByPerspectiveId[record.id] ?? null, request: null }
76
+ }
77
+ return resolveRoleLockInput(expectedUpdatedAtByRoleId, record.roleId, request)
78
+ }
79
+
80
+ function firstExpectedUpdatedAt(expectedUpdatedAtById: ExpectedUpdatedAtById | null | undefined): string | Date | null {
81
+ if (!expectedUpdatedAtById) return null
82
+ for (const value of Object.values(expectedUpdatedAtById)) {
83
+ if (value instanceof Date) return value
84
+ if (typeof value === 'string' && value.trim().length > 0) return value
85
+ }
86
+ return null
87
+ }
88
+
89
+ function throwMissingRoleRecordVersionConflict(record: RolePerspective): never {
90
+ const current = record.updatedAt instanceof Date && Number.isFinite(record.updatedAt.getTime())
91
+ ? record.updatedAt.toISOString()
92
+ : new Date(0).toISOString()
93
+ throw new CrudHttpError(409, buildOptimisticLockConflictBody(current, current))
94
+ }
95
+
47
96
  const CACHE_TTL_MS = 5 * 60 * 1000
48
97
 
49
98
  const nullish = <T extends string | null | undefined>(value: T): string | null =>
@@ -242,7 +291,7 @@ export async function saveUserPerspective(
242
291
  throw Object.assign(new Error('Perspective not found'), { code: 'NOT_FOUND' })
243
292
  }
244
293
  enforceCommandOptimisticLock({
245
- resourceKind: 'perspectives.perspective',
294
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
246
295
  resourceId: entity.id,
247
296
  current: entity.updatedAt ?? null,
248
297
  request: options.request ?? null,
@@ -308,7 +357,12 @@ export async function saveUserPerspective(
308
357
  export async function deleteUserPerspective(
309
358
  em: EntityManager,
310
359
  cache: CacheStrategy | null | undefined,
311
- options: { scope: PerspectiveScope; tableId: string; perspectiveId: string },
360
+ options: {
361
+ scope: PerspectiveScope
362
+ tableId: string
363
+ perspectiveId: string
364
+ request?: Request | Headers | null
365
+ },
312
366
  ): Promise<boolean> {
313
367
  const { scope, tableId, perspectiveId } = options
314
368
  const tenantId = scope.tenantId ?? null
@@ -322,7 +376,21 @@ export async function deleteUserPerspective(
322
376
  tableId,
323
377
  deletedAt: null,
324
378
  })
325
- if (!existing) return false
379
+ if (!existing) {
380
+ enforceRecordGoneIsConflict({
381
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
382
+ resourceId: perspectiveId,
383
+ request: options.request ?? null,
384
+ })
385
+ return false
386
+ }
387
+
388
+ enforceCommandOptimisticLock({
389
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
390
+ resourceId: existing.id,
391
+ current: existing.updatedAt ?? null,
392
+ request: options.request ?? null,
393
+ })
326
394
 
327
395
  existing.deletedAt = new Date()
328
396
  existing.isDefault = false
@@ -343,6 +411,9 @@ export async function saveRolePerspectives(
343
411
  tenantId?: string | null
344
412
  organizationId?: string | null
345
413
  input: RolePerspectiveSaveInput
414
+ expectedUpdatedAtByRoleId?: ExpectedUpdatedAtById
415
+ expectedUpdatedAtByPerspectiveId?: ExpectedUpdatedAtById
416
+ request?: Request | Headers | null
346
417
  },
347
418
  ): Promise<ResolvedRolePerspective[]> {
348
419
  const { tableId, input } = options
@@ -351,7 +422,7 @@ export async function saveRolePerspectives(
351
422
  const now = new Date()
352
423
  const touchedRoleIds = new Set<string>()
353
424
 
354
- const results: ResolvedRolePerspective[] = []
425
+ const resultRecords: RolePerspective[] = []
355
426
 
356
427
  // Prefetch every matching role perspective in a single query, then index by role id
357
428
  // so the loop resolves create/update without a lookup per role.
@@ -367,6 +438,22 @@ export async function saveRolePerspectives(
367
438
  })
368
439
  for (const existing of existingRecords) recordByRole.set(existing.roleId, existing)
369
440
  }
441
+ const defaultRecordsByRole = new Map<string, RolePerspective[]>()
442
+ if (input.setDefault === true && input.roleIds.length) {
443
+ const existingDefaultRecords = await em.find(RolePerspective, {
444
+ roleId: { $in: input.roleIds },
445
+ tableId,
446
+ tenantId,
447
+ organizationId,
448
+ isDefault: true,
449
+ deletedAt: null,
450
+ })
451
+ for (const existing of existingDefaultRecords) {
452
+ const records = defaultRecordsByRole.get(existing.roleId) ?? []
453
+ records.push(existing)
454
+ defaultRecordsByRole.set(existing.roleId, records)
455
+ }
456
+ }
370
457
 
371
458
  for (const roleId of input.roleIds) {
372
459
  let record = recordByRole.get(roleId) ?? null
@@ -385,6 +472,17 @@ export async function saveRolePerspectives(
385
472
  em.persist(record)
386
473
  recordByRole.set(roleId, record)
387
474
  } else {
475
+ enforceCommandOptimisticLock({
476
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
477
+ resourceId: record.id,
478
+ current: record.updatedAt ?? null,
479
+ ...resolveRoleRecordLockInput(
480
+ options.expectedUpdatedAtByPerspectiveId,
481
+ options.expectedUpdatedAtByRoleId,
482
+ record,
483
+ options.request ?? null,
484
+ ),
485
+ })
388
486
  record.settingsJson = input.settings
389
487
  record.updatedAt = now
390
488
  if (input.setDefault === true) record.isDefault = true
@@ -392,6 +490,20 @@ export async function saveRolePerspectives(
392
490
  }
393
491
 
394
492
  if (input.setDefault === true) {
493
+ for (const defaultRecord of defaultRecordsByRole.get(roleId) ?? []) {
494
+ if (defaultRecord.id === record.id) continue
495
+ enforceCommandOptimisticLock({
496
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
497
+ resourceId: defaultRecord.id,
498
+ current: defaultRecord.updatedAt ?? null,
499
+ ...resolveRoleRecordLockInput(
500
+ options.expectedUpdatedAtByPerspectiveId,
501
+ options.expectedUpdatedAtByRoleId,
502
+ defaultRecord,
503
+ options.request ?? null,
504
+ ),
505
+ })
506
+ }
395
507
  await em.nativeUpdate(
396
508
  RolePerspective,
397
509
  {
@@ -400,6 +512,7 @@ export async function saveRolePerspectives(
400
512
  tenantId,
401
513
  organizationId,
402
514
  id: { $ne: record.id } as any,
515
+ isDefault: true,
403
516
  deletedAt: null,
404
517
  },
405
518
  { isDefault: false, updatedAt: now },
@@ -408,7 +521,7 @@ export async function saveRolePerspectives(
408
521
  }
409
522
 
410
523
  touchedRoleIds.add(roleId)
411
- results.push(toResolvedRolePerspective(record))
524
+ resultRecords.push(record)
412
525
  }
413
526
 
414
527
  if (input.roleIds.length) {
@@ -420,7 +533,7 @@ export async function saveRolePerspectives(
420
533
  await cache.deleteByTags(tags)
421
534
  }
422
535
 
423
- return results
536
+ return resultRecords.map(toResolvedRolePerspective)
424
537
  }
425
538
 
426
539
  export async function clearRolePerspectives(
@@ -431,6 +544,9 @@ export async function clearRolePerspectives(
431
544
  tenantId?: string | null
432
545
  organizationId?: string | null
433
546
  roleIds: string[]
547
+ expectedUpdatedAtByRoleId?: ExpectedUpdatedAtById
548
+ expectedUpdatedAtByPerspectiveId?: ExpectedUpdatedAtById
549
+ request?: Request | Headers | null
434
550
  },
435
551
  ): Promise<number> {
436
552
  const { tableId, roleIds } = options
@@ -438,6 +554,54 @@ export async function clearRolePerspectives(
438
554
  const organizationId = options.organizationId ?? null
439
555
  if (!roleIds.length) return 0
440
556
 
557
+ const existingRecords = await em.find(RolePerspective, {
558
+ roleId: { $in: roleIds as any },
559
+ tableId,
560
+ tenantId,
561
+ organizationId,
562
+ deletedAt: null,
563
+ })
564
+ const recordsByRole = new Map<string, RolePerspective[]>()
565
+ for (const record of existingRecords) {
566
+ const records = recordsByRole.get(record.roleId) ?? []
567
+ records.push(record)
568
+ recordsByRole.set(record.roleId, records)
569
+ }
570
+
571
+ for (const roleId of roleIds) {
572
+ const records = recordsByRole.get(roleId) ?? []
573
+ const lockInput = resolveRoleLockInput(options.expectedUpdatedAtByRoleId, roleId, options.request ?? null)
574
+ if (!records.length) {
575
+ const expected = firstExpectedUpdatedAt(options.expectedUpdatedAtByPerspectiveId)
576
+ enforceRecordGoneIsConflict({
577
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
578
+ resourceId: roleId,
579
+ expected: expected ?? lockInput.expected,
580
+ request: expected ? null : lockInput.request,
581
+ })
582
+ continue
583
+ }
584
+ for (const record of records) {
585
+ if (
586
+ options.expectedUpdatedAtByPerspectiveId
587
+ && !Object.prototype.hasOwnProperty.call(options.expectedUpdatedAtByPerspectiveId, record.id)
588
+ ) {
589
+ throwMissingRoleRecordVersionConflict(record)
590
+ }
591
+ enforceCommandOptimisticLock({
592
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
593
+ resourceId: record.id,
594
+ current: record.updatedAt ?? null,
595
+ ...resolveRoleRecordLockInput(
596
+ options.expectedUpdatedAtByPerspectiveId,
597
+ options.expectedUpdatedAtByRoleId,
598
+ record,
599
+ options.request ?? null,
600
+ ),
601
+ })
602
+ }
603
+ }
604
+
441
605
  const affected = await em.nativeUpdate(
442
606
  RolePerspective,
443
607
  {