@open-mercato/core 0.6.6-develop.6398.1.013efe913e → 0.6.6-develop.6401.1.8c07df34a7

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.
Files changed (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/lib/backendChrome.js +3 -2
  3. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  4. package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js +4 -13
  5. package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js.map +2 -2
  6. package/dist/modules/currencies/backend/currencies/page.meta.js +1 -1
  7. package/dist/modules/currencies/backend/currencies/page.meta.js.map +2 -2
  8. package/dist/modules/currencies/backend/exchange-rates/page.meta.js +1 -0
  9. package/dist/modules/currencies/backend/exchange-rates/page.meta.js.map +2 -2
  10. package/dist/modules/entities/api/definitions.batch.js +29 -2
  11. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  12. package/dist/modules/entities/api/definitions.js +9 -2
  13. package/dist/modules/entities/api/definitions.js.map +2 -2
  14. package/dist/modules/entities/api/definitions.manage.js +14 -3
  15. package/dist/modules/entities/api/definitions.manage.js.map +2 -2
  16. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +43 -17
  17. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  18. package/dist/modules/entities/lib/definitions-version.js +55 -0
  19. package/dist/modules/entities/lib/definitions-version.js.map +7 -0
  20. package/dist/modules/resources/backend/resources/resources/[id]/page.js +4 -6
  21. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  22. package/package.json +7 -7
  23. package/src/modules/auth/lib/backendChrome.tsx +3 -2
  24. package/src/modules/currencies/backend/config/currency-fetching/page.meta.ts +4 -15
  25. package/src/modules/currencies/backend/currencies/page.meta.ts +1 -1
  26. package/src/modules/currencies/backend/exchange-rates/page.meta.ts +1 -0
  27. package/src/modules/entities/api/definitions.batch.ts +38 -1
  28. package/src/modules/entities/api/definitions.manage.ts +14 -1
  29. package/src/modules/entities/api/definitions.ts +10 -1
  30. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +53 -17
  31. package/src/modules/entities/lib/definitions-version.ts +102 -0
  32. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +4 -6
@@ -4,7 +4,9 @@ import { useRouter, useSearchParams } from 'next/navigation'
4
4
  import { useQueryClient } from '@tanstack/react-query'
5
5
  import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
6
6
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
- import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
9
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
8
10
  import { invalidateCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDefs'
9
11
  import { upsertCustomEntitySchema, upsertCustomFieldDefSchema } from '@open-mercato/core/modules/entities/data/validators'
10
12
  import { z } from 'zod'
@@ -31,7 +33,12 @@ export type EntitySource = 'code' | 'custom'
31
33
  type EntitiesListResponse = { items?: Array<Record<string, unknown>> }
32
34
  type FieldsetGroup = { code: string; title?: string; hint?: string }
33
35
  type FieldsetDefinition = { code: string; label: string; icon?: string; description?: string; groups?: FieldsetGroup[] }
34
- type DefinitionsManageResponse = { items?: any[]; deletedKeys?: string[]; fieldsets?: FieldsetDefinition[]; settings?: { singleFieldsetPerRecord?: boolean } }
36
+ type DefinitionsManageResponse = { items?: any[]; deletedKeys?: string[]; fieldsets?: FieldsetDefinition[]; settings?: { singleFieldsetPerRecord?: boolean }; version?: string | null }
37
+ type DefinitionsBatchResponse = { ok?: boolean; version?: string | null }
38
+
39
+ function readVersionToken(value: unknown): string | null {
40
+ return typeof value === 'string' && value.length > 0 ? value : null
41
+ }
35
42
 
36
43
  type DefErrors = FieldDefinitionError
37
44
 
@@ -48,6 +55,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
48
55
  const [entityFormLoading, setEntityFormLoading] = useState(true)
49
56
  const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; updatedAt?: string }>({})
50
57
  const [defs, setDefs] = useState<Def[]>([])
58
+ const [defsVersion, setDefsVersion] = useState<string | null>(null)
51
59
  const [orderDirty, setOrderDirty] = useState(false)
52
60
  const [orderSaving, setOrderSaving] = useState(false)
53
61
  const listRef = React.useRef<HTMLDivElement | null>(null)
@@ -197,6 +205,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
197
205
  (a, b) => Number(a.configJson?.priority ?? 0) - Number(b.configJson?.priority ?? 0)
198
206
  )
199
207
  setDefs(loaded)
208
+ setDefsVersion(readVersionToken(json.version))
200
209
  setDefErrors({})
201
210
  setDeletedKeys(Array.isArray(json.deletedKeys) ? json.deletedKeys : [])
202
211
  const loadedFieldsets = Array.isArray(json.fieldsets) ? json.fieldsets : []
@@ -253,6 +262,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
253
262
  (a, b) => Number(a.configJson?.priority ?? 0) - Number(b.configJson?.priority ?? 0)
254
263
  )
255
264
  setDefs(loaded)
265
+ setDefsVersion(readVersionToken(j2.version))
256
266
  setDeletedKeys(Array.isArray(j2.deletedKeys) ? j2.deletedKeys : [])
257
267
  flash(`Restored ${key}`, 'success')
258
268
  await invalidateCustomFieldDefs(queryClient, entityId)
@@ -278,14 +288,19 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
278
288
  isActive: d.isActive !== false,
279
289
  })),
280
290
  }
281
- const call = await apiCall('/api/entities/definitions.batch', {
282
- method: 'POST',
283
- headers: { 'content-type': 'application/json' },
284
- body: JSON.stringify(payload),
285
- })
291
+ const call = await withScopedApiRequestHeaders(
292
+ buildOptimisticLockHeader(defsVersion),
293
+ () => apiCall<DefinitionsBatchResponse>('/api/entities/definitions.batch', {
294
+ method: 'POST',
295
+ headers: { 'content-type': 'application/json' },
296
+ body: JSON.stringify(payload),
297
+ }),
298
+ )
286
299
  if (!call.ok) {
300
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
287
301
  await raiseCrudError(call.response, 'Failed to save definitions')
288
302
  }
303
+ setDefsVersion(readVersionToken(call.result?.version))
289
304
  await invalidateCustomFieldDefs(queryClient, entityId)
290
305
  router.push(`/backend/entities/user?flash=Definitions%20saved&type=success`)
291
306
  } catch (e: any) {
@@ -300,7 +315,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
300
315
  if (!def) return
301
316
  if (def.key) {
302
317
  try {
303
- const call = await apiCall('/api/entities/definitions', {
318
+ const call = await apiCall<DefinitionsBatchResponse>('/api/entities/definitions', {
304
319
  method: 'DELETE',
305
320
  headers: { 'content-type': 'application/json' },
306
321
  body: JSON.stringify({ entityId, key: def.key }),
@@ -308,6 +323,9 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
308
323
  if (!call.ok) {
309
324
  await raiseCrudError(call.response, 'Failed to delete field')
310
325
  }
326
+ // Keep the optimistic-lock token in sync after an out-of-band delete so a
327
+ // later Save does not falsely 409 against our own change (issue #3152).
328
+ setDefsVersion(readVersionToken(call.result?.version))
311
329
  } catch (error) {
312
330
  const message = error instanceof Error ? error.message : 'Failed to delete field'
313
331
  flash(message, 'error')
@@ -364,12 +382,23 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
364
382
  isActive: d.isActive !== false,
365
383
  })),
366
384
  }
367
- const call = await apiCall('/api/entities/definitions.batch', {
368
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload)
369
- })
385
+ const call = await withScopedApiRequestHeaders(
386
+ buildOptimisticLockHeader(defsVersion),
387
+ () => apiCall<DefinitionsBatchResponse>('/api/entities/definitions.batch', {
388
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload),
389
+ }),
390
+ )
370
391
  if (!call.ok) {
392
+ // A stale reorder auto-save (another tab changed the schema first) returns a
393
+ // 409; surface the shared conflict bar and stop the retry loop instead of a
394
+ // generic flash (issue #3152).
395
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) {
396
+ setOrderDirty(false)
397
+ return
398
+ }
371
399
  await raiseCrudError(call.response, 'Failed to save order')
372
400
  }
401
+ setDefsVersion(readVersionToken(call.result?.version))
373
402
  setOrderDirty(false)
374
403
  flash('Order saved', 'success')
375
404
  await invalidateCustomFieldDefs(queryClient, entityId)
@@ -502,18 +531,25 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
502
531
  fieldsets: buildFieldsetPayload(),
503
532
  singleFieldsetPerRecord,
504
533
  })
505
- const callDefs = await apiCall('/api/entities/definitions.batch', {
506
- method: 'POST',
507
- headers: { 'content-type': 'application/json' },
508
- body: JSON.stringify(defsPayload),
509
- })
534
+ // Send the aggregate schema version so a concurrent edit is rejected with a 409
535
+ // instead of silently overwriting the other tab (issue #3152). CrudForm's submit
536
+ // catch detects the optimistic-lock conflict and shows the shared conflict bar.
537
+ const callDefs = await withScopedApiRequestHeaders(
538
+ buildOptimisticLockHeader(defsVersion),
539
+ () => apiCall<DefinitionsBatchResponse>('/api/entities/definitions.batch', {
540
+ method: 'POST',
541
+ headers: { 'content-type': 'application/json' },
542
+ body: JSON.stringify(defsPayload),
543
+ }),
544
+ )
510
545
  if (!callDefs.ok) {
511
546
  await raiseCrudError(callDefs.response, 'Failed to save definitions')
512
547
  }
548
+ setDefsVersion(readVersionToken(callDefs.result?.version))
513
549
  try { window.dispatchEvent(new Event('om:refresh-sidebar')) } catch {}
514
550
  await invalidateCustomFieldDefs(queryClient, entityId)
515
551
  flash('Definitions saved', 'success')
516
- }, [buildFieldsetPayload, defs, entityId, entitySource, queryClient, singleFieldsetPerRecord, validateAll])
552
+ }, [buildFieldsetPayload, defs, defsVersion, entityId, entitySource, queryClient, singleFieldsetPerRecord, validateAll])
517
553
 
518
554
  if (!entityId) {
519
555
  return (
@@ -0,0 +1,102 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ CustomFieldDef,
4
+ CustomFieldEntityConfig,
5
+ } from '@open-mercato/core/modules/entities/data/entities'
6
+
7
+ /**
8
+ * Aggregate optimistic-lock version for an entity's *definition schema*
9
+ * (issue #3152).
10
+ *
11
+ * The entity definition edit form (`/backend/entities/user|system/<entityId>`)
12
+ * saves a whole set of `CustomFieldDef` rows (plus fieldset config) via
13
+ * `POST /api/entities/definitions.batch`. There is no single row to lock, so the
14
+ * classic per-record `updated_at` guard cannot apply. Instead we derive one
15
+ * aggregate version token = the newest `updated_at` across the rows that make up
16
+ * the editable schema for the entity in the caller's scope:
17
+ *
18
+ * - `CustomFieldDef` (active + tombstoned) — a field add/edit/delete bumps it,
19
+ * - `CustomFieldEntityConfig` — a fieldset/settings change bumps it.
20
+ *
21
+ * The `CustomEntity` metadata row is deliberately NOT part of this token: the
22
+ * form's Save first PUTs entity metadata (`POST /api/entities/entities`, which
23
+ * bumps `CustomEntity.updatedAt` and enforces its own optimistic lock) and only
24
+ * then PUTs the definitions batch — folding the metadata row in here would make
25
+ * the batch 409 against the form's OWN just-completed metadata save. Custom
26
+ * entities therefore keep their concurrent-edit protection from the metadata
27
+ * lock (primary) with the definitions token as defense-in-depth; system/code
28
+ * entities (no metadata row, no metadata PUT) rely on the definitions token.
29
+ * A schema with zero definitions and no config yields `null`, so locking
30
+ * degrades to a no-op for that first-ever concurrent add edge case.
31
+ *
32
+ * Both the read path (`definitions.manage` GET) and the mutating endpoints must
33
+ * compute this with identical scope inputs so the token round-trips without
34
+ * false conflicts.
35
+ */
36
+ export type EntityDefinitionsVersionScope = {
37
+ entityId: string
38
+ tenantId: string | null
39
+ organizationId: string | null
40
+ }
41
+
42
+ function buildVisibleScopeWhere(scope: EntityDefinitionsVersionScope): Record<string, unknown> {
43
+ const organizationCandidates: Array<{ organizationId: string | null }> = [{ organizationId: null }]
44
+ if (scope.organizationId) organizationCandidates.unshift({ organizationId: scope.organizationId })
45
+
46
+ const tenantCandidates: Array<{ tenantId: string | null }> = [{ tenantId: null }]
47
+ if (scope.tenantId) tenantCandidates.unshift({ tenantId: scope.tenantId })
48
+
49
+ return {
50
+ entityId: scope.entityId,
51
+ $and: [
52
+ { $or: organizationCandidates },
53
+ { $or: tenantCandidates },
54
+ ],
55
+ }
56
+ }
57
+
58
+ function toMillis(value: unknown): number {
59
+ if (value instanceof Date) {
60
+ const ms = value.getTime()
61
+ return Number.isFinite(ms) ? ms : 0
62
+ }
63
+ if (typeof value === 'string' && value.length > 0) {
64
+ const ms = Date.parse(value)
65
+ return Number.isFinite(ms) ? ms : 0
66
+ }
67
+ return 0
68
+ }
69
+
70
+ async function latestUpdatedAtMillis(
71
+ em: EntityManager,
72
+ entity: unknown,
73
+ where: Record<string, unknown>,
74
+ ): Promise<number> {
75
+ if (!entity) return 0
76
+ try {
77
+ const row = await em.findOne(entity as never, where as never, {
78
+ orderBy: { updatedAt: 'desc' } as never,
79
+ fields: ['updatedAt'] as never,
80
+ })
81
+ if (!row || typeof row !== 'object') return 0
82
+ return toMillis((row as Record<string, unknown>).updatedAt)
83
+ } catch {
84
+ // Fail-open: a projection/schema hiccup must never 500 a save — it degrades
85
+ // to "no version available", which the guard treats as a no-op.
86
+ return 0
87
+ }
88
+ }
89
+
90
+ export async function resolveEntityDefinitionsVersion(
91
+ em: EntityManager,
92
+ scope: EntityDefinitionsVersionScope,
93
+ ): Promise<string | null> {
94
+ const where = buildVisibleScopeWhere(scope)
95
+ const [defMillis, configMillis] = await Promise.all([
96
+ latestUpdatedAtMillis(em, CustomFieldDef, where),
97
+ latestUpdatedAtMillis(em, CustomFieldEntityConfig, where),
98
+ ])
99
+ const newest = Math.max(defMillis, configMillis)
100
+ if (!newest) return null
101
+ return new Date(newest).toISOString()
102
+ }
@@ -453,12 +453,10 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
453
453
  const handleTagsSave = React.useCallback(
454
454
  async ({ next, added, removed }: { next: TagOption[]; added: TagOption[]; removed: TagOption[] }) => {
455
455
  if (!resourceId) return
456
- for (const tag of added) {
457
- await assignTag(tag.id)
458
- }
459
- for (const tag of removed) {
460
- await unassignTag(tag.id)
461
- }
456
+ await Promise.all([
457
+ ...added.map((tag) => assignTag(tag.id)),
458
+ ...removed.map((tag) => unassignTag(tag.id)),
459
+ ])
462
460
  setTags(next)
463
461
  flash(t('resources.resources.tags.success', 'Tags updated.'), 'success')
464
462
  },