@open-mercato/core 0.6.6-develop.6300.1.08ff40c897 → 0.6.6-develop.6304.1.4cf2b975cb

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.
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
5
5
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
6
7
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
7
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
9
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -146,6 +147,9 @@ function buildScheduleEditors(
146
147
  export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
147
148
  const t = useT()
148
149
  const scopeVersion = useOrganizationScopeVersion()
150
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
151
+ contextId: 'data_sync.integrationTab',
152
+ })
149
153
  const [option, setOption] = React.useState<SyncOption | null>(null)
150
154
  const [schedules, setSchedules] = React.useState<Record<string, SyncScheduleEditorState>>({})
151
155
  const [isLoading, setIsLoading] = React.useState(true)
@@ -222,18 +226,32 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
222
226
  setRunningKey(scheduleKey)
223
227
  try {
224
228
  const scheduleState = schedules[scheduleKey] ?? buildDefaultScheduleState(entityType)
225
- // optimistic-lock-exempt: starts a new sync run (create), not a concurrent record edit
226
- const call = await apiCall<{ id: string }>('/api/data_sync/run', {
227
- method: 'POST',
228
- headers: { 'content-type': 'application/json' },
229
- body: JSON.stringify({
229
+ const call = await runMutation({
230
+ // optimistic-lock-exempt: starts a new sync run (create), not a concurrent record edit
231
+ operation: () => apiCall<{ id: string }>('/api/data_sync/run', {
232
+ method: 'POST',
233
+ headers: { 'content-type': 'application/json' },
234
+ body: JSON.stringify({
235
+ integrationId: props.integrationId,
236
+ entityType,
237
+ direction,
238
+ fullSync: scheduleState.fullSync,
239
+ batchSize: 100,
240
+ }),
241
+ }, { fallback: null }),
242
+ mutationPayload: {
230
243
  integrationId: props.integrationId,
231
244
  entityType,
232
245
  direction,
233
246
  fullSync: scheduleState.fullSync,
234
247
  batchSize: 100,
235
- }),
236
- }, { fallback: null })
248
+ },
249
+ context: {
250
+ operation: 'create',
251
+ actionId: 'start-sync-run',
252
+ integrationId: props.integrationId,
253
+ },
254
+ })
237
255
 
238
256
  if (!call.ok) {
239
257
  throw new Error((call.result as { error?: string } | null)?.error ?? 'Failed to start sync')
@@ -246,7 +264,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
246
264
  } finally {
247
265
  setRunningKey(null)
248
266
  }
249
- }, [option?.canStartRun, props.hasCredentials, props.integrationId, props.isEnabled, schedules, t])
267
+ }, [option?.canStartRun, props.hasCredentials, props.integrationId, props.isEnabled, runMutation, schedules, t])
250
268
 
251
269
  const handleSaveSchedule = React.useCallback(async (entityType: string, direction: 'import' | 'export', scheduleKey: string) => {
252
270
  const scheduleState = schedules[scheduleKey] ?? buildDefaultScheduleState(entityType)
@@ -255,23 +273,40 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
255
273
  // Keyed upsert (POST). When the server resolves an existing row the save
256
274
  // version-checks against this schedule's loaded `updatedAt`; for a brand
257
275
  // new row the header is empty so the create path is unaffected.
258
- const call = await withScopedApiRequestHeaders(
259
- buildOptimisticLockHeader(scheduleState.updatedAt),
260
- () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
261
- method: 'POST',
262
- headers: { 'content-type': 'application/json' },
263
- body: JSON.stringify({
264
- integrationId: props.integrationId,
265
- entityType,
266
- direction,
267
- scheduleType: scheduleState.scheduleType,
268
- scheduleValue: scheduleState.scheduleValue,
269
- timezone: scheduleState.timezone,
270
- fullSync: scheduleState.fullSync,
271
- isEnabled: scheduleState.isEnabled,
272
- }),
273
- }, { fallback: null }),
274
- )
276
+ const call = await runMutation({
277
+ operation: () => withScopedApiRequestHeaders(
278
+ buildOptimisticLockHeader(scheduleState.updatedAt),
279
+ () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
280
+ method: 'POST',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({
283
+ integrationId: props.integrationId,
284
+ entityType,
285
+ direction,
286
+ scheduleType: scheduleState.scheduleType,
287
+ scheduleValue: scheduleState.scheduleValue,
288
+ timezone: scheduleState.timezone,
289
+ fullSync: scheduleState.fullSync,
290
+ isEnabled: scheduleState.isEnabled,
291
+ }),
292
+ }, { fallback: null }),
293
+ ),
294
+ mutationPayload: {
295
+ integrationId: props.integrationId,
296
+ entityType,
297
+ direction,
298
+ scheduleType: scheduleState.scheduleType,
299
+ scheduleValue: scheduleState.scheduleValue,
300
+ timezone: scheduleState.timezone,
301
+ fullSync: scheduleState.fullSync,
302
+ isEnabled: scheduleState.isEnabled,
303
+ },
304
+ context: {
305
+ operation: 'update',
306
+ actionId: 'save-sync-schedule',
307
+ integrationId: props.integrationId,
308
+ },
309
+ })
275
310
 
276
311
  if (!call.ok || !call.result) {
277
312
  const conflictError = Object.assign(
@@ -304,7 +339,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
304
339
  } finally {
305
340
  setSavingKey(null)
306
341
  }
307
- }, [props.integrationId, schedules, t, updateScheduleEditor])
342
+ }, [props.integrationId, runMutation, schedules, t, updateScheduleEditor])
308
343
 
309
344
  const handleDeleteSchedule = React.useCallback(async (entityType: string, scheduleKey: string) => {
310
345
  const scheduleState = schedules[scheduleKey]
@@ -315,12 +350,21 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
315
350
 
316
351
  setDeletingKey(scheduleKey)
317
352
  try {
318
- const call = await withScopedApiRequestHeaders(
319
- buildOptimisticLockHeader(scheduleState.updatedAt),
320
- () => apiCall(`/api/data_sync/schedules/${encodeURIComponent(scheduleState.id as string)}`, {
321
- method: 'DELETE',
322
- }, { fallback: null }),
323
- )
353
+ const call = await runMutation({
354
+ operation: () => withScopedApiRequestHeaders(
355
+ buildOptimisticLockHeader(scheduleState.updatedAt),
356
+ () => apiCall(`/api/data_sync/schedules/${encodeURIComponent(scheduleState.id as string)}`, {
357
+ method: 'DELETE',
358
+ }, { fallback: null }),
359
+ ),
360
+ mutationPayload: {
361
+ scheduleId: scheduleState.id,
362
+ },
363
+ context: {
364
+ operation: 'delete',
365
+ actionId: 'delete-sync-schedule',
366
+ },
367
+ })
324
368
 
325
369
  if (!call.ok) {
326
370
  throw new Error((call.result as { error?: string } | null)?.error ?? 'Failed to delete schedule')
@@ -337,7 +381,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
337
381
  } finally {
338
382
  setDeletingKey(null)
339
383
  }
340
- }, [schedules, t, updateScheduleEditor])
384
+ }, [runMutation, schedules, t, updateScheduleEditor])
341
385
 
342
386
  if (isLoading) {
343
387
  return (
@@ -178,6 +178,13 @@ export async function DELETE(req: Request, ctx: { params?: { dictionaryId?: stri
178
178
  })
179
179
  const dictionary = await loadDictionary(context, dictionaryId)
180
180
  const entry = await loadEntry(context, dictionary, entryId)
181
+ enforceCommandOptimisticLock({
182
+ resourceKind: 'dictionaries.entry',
183
+ resourceId: entry.id,
184
+ current: entry.updatedAt ?? null,
185
+ request: req,
186
+ })
187
+
181
188
  const guardUserId = resolveDictionaryActorId(context.auth)
182
189
  const guardResult = await validateCrudMutationGuard(context.container, {
183
190
  tenantId: context.tenantId,
@@ -236,6 +236,13 @@ export async function DELETE(req: Request, ctx: { params?: { dictionaryId?: stri
236
236
  const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })
237
237
  const dictionary = await loadDictionary(context, dictionaryId)
238
238
 
239
+ enforceCommandOptimisticLock({
240
+ resourceKind: 'dictionaries.dictionary',
241
+ resourceId: dictionary.id,
242
+ current: dictionary.updatedAt ?? null,
243
+ request: req,
244
+ })
245
+
239
246
  const guardUserId = resolveDictionaryActorId(context.auth)
240
247
  const guardResult = await validateCrudMutationGuard(context.container, {
241
248
  tenantId: context.tenantId,
@@ -189,6 +189,14 @@ export async function GET(req: Request) {
189
189
  return q
190
190
  }
191
191
 
192
+ // Audited for #3386 rollout (P3): sort is on m.sent_at (a plain timestamp —
193
+ // not in the messages:message encryption map whose encrypted fields are:
194
+ // subject, body, external_email, external_name, action_data, action_result).
195
+ // The handler already uses the correct two-phase shape: Kysely SQL
196
+ // ORDER BY + LIMIT/OFFSET produces a bounded page of IDs, then
197
+ // findWithDecryption is called only for those IDs — never for the full
198
+ // result set. The #3278 unbounded-decrypt hazard does not apply here.
199
+ // Covered by __tests__/list.test.ts.
192
200
  const countResult = await buildBaseQuery()
193
201
  .select(sql<number>`count(*)`.as('count'))
194
202
  .executeTakeFirst() as { count: string | number } | undefined