@open-mercato/core 0.6.6-develop.6402.1.080aab8ec9 → 0.6.6-develop.6403.1.b57089b6fe

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/core",
3
- "version": "0.6.6-develop.6402.1.080aab8ec9",
3
+ "version": "0.6.6-develop.6403.1.b57089b6fe",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -248,16 +248,16 @@
248
248
  "zod": "^4.4.3"
249
249
  },
250
250
  "peerDependencies": {
251
- "@open-mercato/ai-assistant": "0.6.6-develop.6402.1.080aab8ec9",
252
- "@open-mercato/shared": "0.6.6-develop.6402.1.080aab8ec9",
253
- "@open-mercato/ui": "0.6.6-develop.6402.1.080aab8ec9",
251
+ "@open-mercato/ai-assistant": "0.6.6-develop.6403.1.b57089b6fe",
252
+ "@open-mercato/shared": "0.6.6-develop.6403.1.b57089b6fe",
253
+ "@open-mercato/ui": "0.6.6-develop.6403.1.b57089b6fe",
254
254
  "react": "^19.0.0",
255
255
  "react-dom": "^19.0.0"
256
256
  },
257
257
  "devDependencies": {
258
- "@open-mercato/ai-assistant": "0.6.6-develop.6402.1.080aab8ec9",
259
- "@open-mercato/shared": "0.6.6-develop.6402.1.080aab8ec9",
260
- "@open-mercato/ui": "0.6.6-develop.6402.1.080aab8ec9",
258
+ "@open-mercato/ai-assistant": "0.6.6-develop.6403.1.b57089b6fe",
259
+ "@open-mercato/shared": "0.6.6-develop.6403.1.b57089b6fe",
260
+ "@open-mercato/ui": "0.6.6-develop.6403.1.b57089b6fe",
261
261
  "@testing-library/dom": "^10.4.1",
262
262
  "@testing-library/jest-dom": "^6.9.1",
263
263
  "@testing-library/react": "^16.3.1",
@@ -123,6 +123,16 @@ export const openApi = {
123
123
  { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },
124
124
  { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
125
125
  { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },
126
+ {
127
+ status: 409,
128
+ description: 'Optimistic lock conflict',
129
+ schema: z.object({
130
+ error: z.string(),
131
+ code: z.literal('optimistic_lock_conflict'),
132
+ currentUpdatedAt: z.string(),
133
+ expectedUpdatedAt: z.string(),
134
+ }),
135
+ },
126
136
  ],
127
137
  },
128
138
  },
@@ -8,14 +8,24 @@ import {
8
8
  type PlannerAvailabilityWeeklyReplaceInput,
9
9
  } from '../data/validators'
10
10
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
- import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
11
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
12
12
  import {
13
13
  enforceCommandOptimisticLock,
14
14
  enforceRecordGoneIsConflict,
15
15
  } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
16
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
17
+ import type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
18
+ import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
16
19
  import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
20
+ import { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'
21
+ import { E } from '#generated/entities.ids.generated'
17
22
 
18
23
  const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
24
+ const AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND = 'planner.availability-rule-set'
25
+
26
+ const availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {
27
+ entityType: E.planner.planner_availability_rule_set,
28
+ }
19
29
 
20
30
  // Canonical resource kind for the parent rule set, matching the tag the CRUD
21
31
  // factory derives for `planner.availability-rule-sets.*` commands. Weekly
@@ -95,6 +105,14 @@ function toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): Availabili
95
105
  }
96
106
  }
97
107
 
108
+ function nextRuleSetUpdatedAt(current: Date | null | undefined, fallback: Date): Date {
109
+ const currentMs = current instanceof Date ? current.getTime() : Number.NaN
110
+ if (Number.isFinite(currentMs) && fallback.getTime() <= currentMs) {
111
+ return new Date(currentMs + 1)
112
+ }
113
+ return fallback
114
+ }
115
+
98
116
  async function loadWeeklySnapshots(
99
117
  em: EntityManager,
100
118
  params: {
@@ -173,12 +191,12 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
173
191
  const em = (ctx.container.resolve('em') as EntityManager).fork()
174
192
  const now = new Date()
175
193
 
176
- await em.transactional(async (trx) => {
177
- // The weekly rules of a rule set are a sub-resource of that rule set: the
178
- // parent is the optimistic-lock consistency boundary. Guard the parent's
179
- // version (so a stale weekly save loses to a concurrent rule-set
180
- // change/delete) and bump its `updated_at` after the replace (so a
181
- // concurrent rule-set delete/update with a stale token conflicts). See #2927.
194
+ // The weekly rules of a rule set are a sub-resource of that rule set: the
195
+ // parent is the optimistic-lock consistency boundary. Guard the parent's
196
+ // version (so a stale weekly save loses to a concurrent rule-set
197
+ // change/delete) and bump its `updated_at` after the replace (so a
198
+ // concurrent rule-set delete/update with a stale token conflicts). See #2927.
199
+ const touchedRuleSet = await em.transactional(async (trx): Promise<PlannerAvailabilityRuleSet | null> => {
182
200
  let ruleSet: PlannerAvailabilityRuleSet | null = null
183
201
  if (parsed.subjectType === 'ruleset') {
184
202
  ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {
@@ -250,13 +268,30 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
250
268
  })
251
269
 
252
270
  if (ruleSet) {
253
- ruleSet.updatedAt = now
271
+ ruleSet.updatedAt = nextRuleSetUpdatedAt(ruleSet.updatedAt, now)
254
272
  trx.persist(ruleSet)
255
273
  }
256
274
 
257
275
  await trx.flush()
276
+ return ruleSet
258
277
  })
259
278
 
279
+ if (touchedRuleSet) {
280
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
281
+ await emitCrudSideEffects({
282
+ dataEngine,
283
+ action: 'updated',
284
+ entity: touchedRuleSet,
285
+ identifiers: {
286
+ id: touchedRuleSet.id,
287
+ organizationId: touchedRuleSet.organizationId,
288
+ tenantId: touchedRuleSet.tenantId,
289
+ },
290
+ events: plannerAvailabilityRuleSetCrudEvents,
291
+ indexer: availabilityRuleSetCrudIndexer,
292
+ })
293
+ }
294
+
260
295
  return { ok: true }
261
296
  },
262
297
  buildLog: async ({ input, snapshots, ctx }) => {
@@ -284,6 +319,9 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
284
319
  after,
285
320
  } satisfies WeeklyUndoPayload,
286
321
  },
322
+ context: parsed.subjectType === 'ruleset'
323
+ ? { cacheAliases: [AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND] }
324
+ : null,
287
325
  }
288
326
  },
289
327
  undo: async ({ logEntry, ctx }) => {
@@ -19,6 +19,7 @@ import { createCrud, deleteCrud, updateCrud } from '@open-mercato/ui/backend/uti
19
19
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
20
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
21
21
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
22
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
22
23
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
23
24
  import { ComboboxInput, TimePicker } from '@open-mercato/ui/backend/inputs'
24
25
  import { DictionaryEntrySelect, type DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
@@ -30,7 +31,6 @@ import {
30
31
  import { useT } from '@open-mercato/shared/lib/i18n/context'
31
32
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
32
33
  import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
33
- import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
34
34
  import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
35
35
  import { deleteAvailabilityRuleSet } from '@open-mercato/core/modules/planner/lib/deleteAvailabilityRuleSet'
36
36
  import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
@@ -778,6 +778,12 @@ export function AvailabilityRulesEditor({
778
778
  void refreshBookedEvents()
779
779
  }, [refreshBookedEvents])
780
780
 
781
+ const refreshAfterRuleSetConflict = React.useCallback(() => {
782
+ void refreshRuleSets()
783
+ void refreshRuleSetRules()
784
+ void refreshAvailability()
785
+ }, [refreshAvailability, refreshRuleSetRules, refreshRuleSets])
786
+
781
787
  const weeklyDraft = React.useMemo(() => buildWeeklyDraft(activeRules), [activeRules])
782
788
  const [weeklyWindows, setWeeklyWindows] = React.useState<WeeklyWindows>(() => cloneWeeklyWindows(weeklyDraft))
783
789
  const weeklyWindowsRef = React.useRef<WeeklyWindows>(cloneWeeklyWindows(weeklyDraft))
@@ -893,14 +899,15 @@ export function AvailabilityRulesEditor({
893
899
  if (!shouldSkipRefresh) {
894
900
  await refreshAvailability()
895
901
  await refreshRuleSetRules()
902
+ if (usingRuleSet) {
903
+ await refreshRuleSets()
904
+ }
896
905
  }
897
906
  if (parentRuleSet) {
898
907
  await refreshRuleSets()
899
908
  }
900
909
  } catch (error) {
901
- if (surfaceRecordConflict(error, t)) {
902
- return
903
- }
910
+ if (surfaceRecordConflict(error, t, { onRefresh: refreshAfterRuleSetConflict })) return
904
911
  const message = error instanceof Error ? error.message : listLabels.saveWeeklyError
905
912
  flash(message, 'error')
906
913
  } finally {
@@ -912,8 +919,9 @@ export function AvailabilityRulesEditor({
912
919
  refreshAvailability,
913
920
  refreshRuleSetRules,
914
921
  refreshRuleSets,
915
- ruleSets,
922
+ refreshAfterRuleSetConflict,
916
923
  effectiveRulesetId,
924
+ ruleSets,
917
925
  subjectId,
918
926
  subjectType,
919
927
  t,
@@ -1180,7 +1188,7 @@ export function AvailabilityRulesEditor({
1180
1188
  refreshRuleSets,
1181
1189
  onSuccess: () => flash(listLabels.ruleSetDeleteSuccess, 'success'),
1182
1190
  onError: (error) => {
1183
- if (surfaceRecordConflict(error, t)) return
1191
+ if (surfaceRecordConflict(error, t, { onRefresh: refreshAfterRuleSetConflict })) return
1184
1192
  console.error('planner.availability-rule-sets.delete', error)
1185
1193
  const normalized = normalizeCrudServerError(error)
1186
1194
  flash(normalized.message ?? listLabels.ruleSetDeleteError, 'error')
@@ -1195,6 +1203,7 @@ export function AvailabilityRulesEditor({
1195
1203
  listLabels.ruleSetDeleteSuccess,
1196
1204
  onRulesetChange,
1197
1205
  refreshAvailability,
1206
+ refreshAfterRuleSetConflict,
1198
1207
  refreshRuleSets,
1199
1208
  ruleSets,
1200
1209
  rulesetId,