@open-mercato/core 0.6.6-develop.6399.1.93ba8ca030 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/lib/backendChrome.js +3 -2
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js +4 -13
- package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/page.meta.js +1 -1
- package/dist/modules/currencies/backend/currencies/page.meta.js.map +2 -2
- package/dist/modules/currencies/backend/exchange-rates/page.meta.js +1 -0
- package/dist/modules/currencies/backend/exchange-rates/page.meta.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +29 -2
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +9 -2
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/definitions.manage.js +14 -3
- package/dist/modules/entities/api/definitions.manage.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js +43 -17
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
- package/dist/modules/entities/lib/definitions-version.js +55 -0
- package/dist/modules/entities/lib/definitions-version.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/lib/backendChrome.tsx +3 -2
- package/src/modules/currencies/backend/config/currency-fetching/page.meta.ts +4 -15
- package/src/modules/currencies/backend/currencies/page.meta.ts +1 -1
- package/src/modules/currencies/backend/exchange-rates/page.meta.ts +1 -0
- package/src/modules/entities/api/definitions.batch.ts +38 -1
- package/src/modules/entities/api/definitions.manage.ts +14 -1
- package/src/modules/entities/api/definitions.ts +10 -1
- package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +53 -17
- package/src/modules/entities/lib/definitions-version.ts +102 -0
|
@@ -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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
368
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
}
|