@open-mercato/core 0.6.8-develop.6992.1.00c90fecff → 0.6.8-develop.6998.1.d2bc46c56c

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 (87) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/sync_run/index.js +2 -0
  3. package/dist/generated/entities/sync_run/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/auth/api/sidebar/preferences/route.js +2 -2
  7. package/dist/modules/auth/api/sidebar/preferences/route.js.map +1 -1
  8. package/dist/modules/auth/lib/backendChrome.js +3 -3
  9. package/dist/modules/auth/lib/backendChrome.js.map +1 -1
  10. package/dist/modules/auth/services/sidebarPreferencesService.js +7 -2
  11. package/dist/modules/auth/services/sidebarPreferencesService.js.map +2 -2
  12. package/dist/modules/data_sync/api/options.js +1 -0
  13. package/dist/modules/data_sync/api/options.js.map +2 -2
  14. package/dist/modules/data_sync/api/run.js +15 -1
  15. package/dist/modules/data_sync/api/run.js.map +2 -2
  16. package/dist/modules/data_sync/api/runs/[id]/retry.js +23 -1
  17. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  18. package/dist/modules/data_sync/api/runs/[id]/route.js +1 -0
  19. package/dist/modules/data_sync/api/runs/[id]/route.js.map +2 -2
  20. package/dist/modules/data_sync/backend/data-sync/page.js +60 -17
  21. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  22. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +34 -2
  23. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  24. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +21 -15
  25. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  26. package/dist/modules/data_sync/components/RunParameterFields.js +135 -0
  27. package/dist/modules/data_sync/components/RunParameterFields.js.map +7 -0
  28. package/dist/modules/data_sync/data/entities.js +3 -0
  29. package/dist/modules/data_sync/data/entities.js.map +2 -2
  30. package/dist/modules/data_sync/data/validators.js +4 -1
  31. package/dist/modules/data_sync/data/validators.js.map +2 -2
  32. package/dist/modules/data_sync/lib/run-parameters.js +108 -0
  33. package/dist/modules/data_sync/lib/run-parameters.js.map +7 -0
  34. package/dist/modules/data_sync/lib/start-run.js +1 -0
  35. package/dist/modules/data_sync/lib/start-run.js.map +2 -2
  36. package/dist/modules/data_sync/lib/sync-engine.js +4 -2
  37. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-run-service.js +1 -0
  39. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  40. package/dist/modules/data_sync/migrations/Migration20260810120000.js +13 -0
  41. package/dist/modules/data_sync/migrations/Migration20260810120000.js.map +7 -0
  42. package/dist/modules/data_sync/workers/sync-scheduled.js +23 -2
  43. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  44. package/dist/modules/query_index/di.js +125 -48
  45. package/dist/modules/query_index/di.js.map +2 -2
  46. package/dist/modules/sales/api/documents/factory.js +39 -1
  47. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  48. package/dist/modules/sales/commands/documents.js +26 -5
  49. package/dist/modules/sales/commands/documents.js.map +2 -2
  50. package/generated/entities/sync_run/index.ts +1 -0
  51. package/generated/entity-fields-registry.ts +1 -0
  52. package/package.json +7 -7
  53. package/src/modules/auth/api/sidebar/preferences/route.ts +2 -2
  54. package/src/modules/auth/lib/backendChrome.tsx +2 -2
  55. package/src/modules/auth/services/sidebarPreferencesService.ts +22 -3
  56. package/src/modules/data_sync/AGENTS.md +83 -8
  57. package/src/modules/data_sync/api/options.ts +1 -0
  58. package/src/modules/data_sync/api/run.ts +17 -0
  59. package/src/modules/data_sync/api/runs/[id]/retry.ts +32 -1
  60. package/src/modules/data_sync/api/runs/[id]/route.ts +1 -0
  61. package/src/modules/data_sync/backend/data-sync/page.tsx +73 -17
  62. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +62 -2
  63. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +28 -15
  64. package/src/modules/data_sync/components/RunParameterFields.tsx +229 -0
  65. package/src/modules/data_sync/data/entities.ts +4 -1
  66. package/src/modules/data_sync/data/validators.ts +3 -0
  67. package/src/modules/data_sync/i18n/de.json +10 -0
  68. package/src/modules/data_sync/i18n/en.json +10 -0
  69. package/src/modules/data_sync/i18n/es.json +10 -0
  70. package/src/modules/data_sync/i18n/ko.json +10 -0
  71. package/src/modules/data_sync/i18n/pl.json +10 -0
  72. package/src/modules/data_sync/lib/adapter.ts +88 -0
  73. package/src/modules/data_sync/lib/run-parameters.ts +196 -0
  74. package/src/modules/data_sync/lib/start-run.ts +2 -0
  75. package/src/modules/data_sync/lib/sync-engine.ts +5 -1
  76. package/src/modules/data_sync/lib/sync-run-service.ts +2 -0
  77. package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +17 -1
  78. package/src/modules/data_sync/migrations/Migration20260810120000.ts +13 -0
  79. package/src/modules/data_sync/workers/sync-scheduled.ts +32 -1
  80. package/src/modules/query_index/di.ts +153 -56
  81. package/src/modules/sales/api/documents/factory.ts +62 -1
  82. package/src/modules/sales/commands/documents.ts +40 -4
  83. package/src/modules/sales/i18n/de.json +1 -0
  84. package/src/modules/sales/i18n/en.json +1 -0
  85. package/src/modules/sales/i18n/es.json +1 -0
  86. package/src/modules/sales/i18n/ko.json +1 -0
  87. package/src/modules/sales/i18n/pl.json +1 -0
@@ -14,6 +14,7 @@ export const last_error = "last_error";
14
14
  export const progress_job_id = "progress_job_id";
15
15
  export const job_id = "job_id";
16
16
  export const triggered_by = "triggered_by";
17
+ export const parameters = "parameters";
17
18
  export const organization_id = "organization_id";
18
19
  export const tenant_id = "tenant_id";
19
20
  export const created_at = "created_at";
@@ -3096,6 +3096,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
3096
3096
  "progress_job_id": "progress_job_id",
3097
3097
  "job_id": "job_id",
3098
3098
  "triggered_by": "triggered_by",
3099
+ "parameters": "parameters",
3099
3100
  "organization_id": "organization_id",
3100
3101
  "tenant_id": "tenant_id",
3101
3102
  "created_at": "created_at",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.6992.1.00c90fecff",
3
+ "version": "0.6.8-develop.6998.1.d2bc46c56c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.8-develop.6992.1.00c90fecff",
258
- "@open-mercato/shared": "0.6.8-develop.6992.1.00c90fecff",
259
- "@open-mercato/ui": "0.6.8-develop.6992.1.00c90fecff",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.6998.1.d2bc46c56c",
258
+ "@open-mercato/shared": "0.6.8-develop.6998.1.d2bc46c56c",
259
+ "@open-mercato/ui": "0.6.8-develop.6998.1.d2bc46c56c",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.8-develop.6992.1.00c90fecff",
265
- "@open-mercato/shared": "0.6.8-develop.6992.1.00c90fecff",
266
- "@open-mercato/ui": "0.6.8-develop.6992.1.00c90fecff",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.6998.1.d2bc46c56c",
265
+ "@open-mercato/shared": "0.6.8-develop.6998.1.d2bc46c56c",
266
+ "@open-mercato/ui": "0.6.8-develop.6998.1.d2bc46c56c",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^7.0.0",
269
269
  "@testing-library/react": "^16.3.1",
@@ -9,9 +9,9 @@ import {
9
9
  sidebarPreferencesScopeSchema,
10
10
  } from '../../../data/validators'
11
11
  import {
12
+ findSidebarPreference,
12
13
  loadRoleSidebarPreferenceUpdatedAt,
13
14
  loadRoleSidebarPreferences,
14
- loadSidebarPreference,
15
15
  loadSidebarPreferenceUpdatedAt,
16
16
  saveRoleSidebarPreference,
17
17
  saveSidebarPreference,
@@ -204,7 +204,7 @@ export async function GET(req: Request) {
204
204
  // For API key auth, use userId (the actual user) if available
205
205
  const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
206
206
  const settings = effectiveUserId
207
- ? await loadSidebarPreference(em, {
207
+ ? await findSidebarPreference(em, {
208
208
  userId: effectiveUserId,
209
209
  tenantId: auth.tenantId ?? null,
210
210
  organizationId: auth.orgId ?? null,
@@ -33,8 +33,8 @@ import { Role } from '@open-mercato/core/modules/auth/data/entities'
33
33
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
34
34
  import {
35
35
  applySidebarPreference,
36
+ findSidebarPreference,
36
37
  loadFirstRoleSidebarPreference,
37
- loadSidebarPreference,
38
38
  } from '@open-mercato/core/modules/auth/services/sidebarPreferencesService'
39
39
  import type { SidebarPreferencesSettings } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
40
40
 
@@ -436,7 +436,7 @@ export async function resolveBackendChromePayload({
436
436
 
437
437
  const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
438
438
  if (effectiveUserId) {
439
- userPreference = await loadSidebarPreference(em, {
439
+ userPreference = await findSidebarPreference(em, {
440
440
  userId: effectiveUserId,
441
441
  tenantId: scopedTenantId,
442
442
  organizationId: scopedOrganizationId,
@@ -37,10 +37,10 @@ export type SidebarGroupLike<T = Record<string, unknown>> = {
37
37
  weight?: number
38
38
  } & T
39
39
 
40
- export async function loadSidebarPreference(
40
+ export async function findSidebarPreference(
41
41
  em: EntityManager,
42
42
  scope: SidebarPreferenceScope,
43
- ): Promise<SidebarPreferencesSettings> {
43
+ ): Promise<SidebarPreferencesSettings | null> {
44
44
  // Cross-locale: variants & preferences are scoped per (user, tenant, org) only.
45
45
  // The `locale` field on the row is kept for audit / when the row was created.
46
46
  const { userId, tenantId, organizationId } = normalizeScope(scope)
@@ -51,7 +51,26 @@ export async function loadSidebarPreference(
51
51
  undefined,
52
52
  { tenantId, organizationId },
53
53
  )
54
- return normalizeSidebarSettings(existing?.settingsJson as SidebarPreferencesSettings | undefined)
54
+ // `null` means "no saved preference" and is distinct from "a preference exists but is empty":
55
+ // callers layer role defaults beneath the user layout and must skip the user pass entirely,
56
+ // because applying empty settings overwrites the role state instead of merging with it.
57
+ if (!existing) return null
58
+ return normalizeSidebarSettings(existing.settingsJson as SidebarPreferencesSettings | null | undefined)
59
+ }
60
+
61
+ /**
62
+ * @deprecated Since 0.7.1, slated for removal in 0.9.0. Use `findSidebarPreference` and handle
63
+ * `null`. This function fabricates a default settings object for a user with no saved row, which
64
+ * is indistinguishable from a saved-but-empty preference — applying that result erases any role
65
+ * layer underneath it, because `applySidebarPreference` overwrites `hidden` rather than merging.
66
+ * Migration: `(await findSidebarPreference(em, scope)) ?? normalizeSidebarSettings(null)`
67
+ * reproduces this exact return value if you genuinely want the defaults.
68
+ */
69
+ export async function loadSidebarPreference(
70
+ em: EntityManager,
71
+ scope: SidebarPreferenceScope,
72
+ ): Promise<SidebarPreferencesSettings> {
73
+ return (await findSidebarPreference(em, scope)) ?? normalizeSidebarSettings(null)
55
74
  }
56
75
 
57
76
  export async function loadSidebarPreferenceUpdatedAt(
@@ -54,6 +54,7 @@ packages/core/src/modules/data_sync/
54
54
  │ ├── adapter-registry.ts # Register/get adapters by providerKey
55
55
  │ ├── id-mapping.ts # External ID ↔ local ID lookup and storage
56
56
  │ ├── queue.ts # Queue helper for enqueuing sync jobs
57
+ │ ├── run-parameters.ts # Validate/coerce operator run parameters vs. adapter declaration
57
58
  │ ├── sync-engine.ts # Orchestrates streaming import/export with progress
58
59
  │ └── sync-run-service.ts # CRUD for SyncRun + cursor management
59
60
  ├── api/
@@ -97,23 +98,97 @@ Provider modules implement `DataSyncAdapter`:
97
98
 
98
99
  ```typescript
99
100
  interface DataSyncAdapter {
100
- providerKey: string
101
- direction: 'import' | 'export' | 'bidirectional'
102
- supportedEntities: string[]
103
- streamImport(entityType: string, cursor: string | null, config: SyncConfig): AsyncIterable<ImportBatch>
104
- streamExport?(entityType: string, cursor: string | null, config: SyncConfig): AsyncIterable<ExportBatch>
105
- getInitialCursor?(entityType: string): Promise<string | null>
106
- getMapping?(entityType: string): Promise<FieldMapping[]>
101
+ readonly providerKey: string
102
+ readonly direction: 'import' | 'export' | 'bidirectional'
103
+ readonly supportedEntities: string[]
104
+ readonly runMode?: 'generic' | 'provider'
105
+ readonly operationalTelemetry?: boolean
106
+ readonly runParameters?: RunParameter[]
107
+
108
+ streamImport?(input: StreamImportInput): AsyncIterable<ImportBatch>
109
+ streamExport?(input: StreamExportInput): AsyncIterable<ExportBatch>
110
+ getInitialCursor?(input: { entityType: string; scope: TenantScope }): Promise<string | null>
111
+ getMapping(input: { entityType: string; scope: TenantScope }): Promise<DataMapping>
107
112
  persistsSharedCursor?(entityType: string): boolean
108
- validateConnection?(credentials: Record<string, unknown>): Promise<{ valid: boolean; message?: string }>
113
+ validateConnection?(input: {
114
+ entityType: string
115
+ credentials: Record<string, unknown>
116
+ mapping: DataMapping
117
+ scope: TenantScope
118
+ }): Promise<ValidationResult>
109
119
  }
110
120
  ```
111
121
 
122
+ All hooks take a single input object — see `lib/adapter.ts` for the authoritative
123
+ shapes.
124
+
112
125
  Register adapters in your provider module's `di.ts`:
113
126
  ```typescript
114
127
  registerDataSyncAdapter(myAdapter)
115
128
  ```
116
129
 
130
+ ### Run parameters
131
+
132
+ Adapters may declare optional, operator-facing `runParameters`. The dashboard
133
+ renders a generic input per declared parameter, the run API validates and
134
+ coerces the submitted values against the declaration (`lib/run-parameters.ts`),
135
+ and the normalized values are persisted on the run and passed back on
136
+ `StreamImportInput.parameters` / `StreamExportInput.parameters`. Keep
137
+ declarations provider-agnostic — never special-case a provider in `data_sync`.
138
+
139
+ ```typescript
140
+ runParameters: [
141
+ { key: 'dryRun', label: 'Dry run', type: 'boolean', defaultValue: false,
142
+ description: 'Report what would change without writing.' },
143
+ { key: 'startId', label: 'Start id', type: 'number', min: 0 },
144
+ { key: 'mode', label: 'Mode', type: 'select',
145
+ options: [{ value: 'fast' }, { value: 'thorough' }] },
146
+ // Only offered when the orders entity is selected:
147
+ { key: 'bulk', label: 'Bulk reindex', type: 'boolean', entityType: 'sales_orders' },
148
+ ]
149
+ ```
150
+
151
+ Supported types: `boolean`, `string`, `number`, `select`. A parameter may set
152
+ `direction` to apply to only `import` or `export` runs, and `entityType`
153
+ (a `supportedEntities` value or an array of them) to apply only when that
154
+ entity is selected — use it when a knob only makes sense for one entity's run.
155
+ Params without `direction` / `entityType` apply to every run. Blank values fall
156
+ back to `defaultValue`; values are retained across retries.
157
+
158
+ Run parameters are **operator-visible and stored in clear text** on
159
+ `sync_runs.parameters`, and rendered read-only on the run detail page. Never
160
+ declare a parameter that carries a secret — credentials belong in
161
+ `integrationCredentialsService`.
162
+
163
+ **Translation.** `label`, `description` and `placeholder` are literals, so an
164
+ adapter shipping to more than one locale MUST also set `labelKey` /
165
+ `descriptionKey` / `placeholderKey`; the dashboard prefers the key and falls
166
+ back to the literal. Validation failures come back from the API as
167
+ `{ key, code, params, message }` — the UI renders `code` through
168
+ `data_sync.runParameters.errors.<code>` and only uses the English `message` as
169
+ a fallback, so never rely on the sentence text.
170
+
171
+ **Retry re-validates.** `POST /api/data_sync/runs/[id]/retry` re-runs the stored
172
+ values through `normalizeRunParameters` against the *current* declaration:
173
+ parameters you have since removed or re-scoped fall away, and a value that no
174
+ longer satisfies its declaration fails the retry with a 422 instead of reaching
175
+ the adapter. An adapter therefore never receives a set the run API would reject
176
+ today — tighten a bound freely.
177
+
178
+ The integration detail page's schedule table also starts runs, but has no room
179
+ for a parameter form: it submits the declared defaults and refuses the run when
180
+ an applicable parameter is `required` with no `defaultValue`, pointing the
181
+ operator at the Data Sync dashboard. Declare a `defaultValue` for anything that
182
+ should stay launchable from that table.
183
+
184
+ **Recurring runs get your defaults, not operator values.** A `SyncSchedule`
185
+ cannot yet pin a chosen value, but the scheduled worker normalizes an empty
186
+ input against your declaration, so a scheduled run hands you the same set an
187
+ untouched dashboard form would — never an empty object. Write your adapter
188
+ against the defaults, not against `undefined`. A default that violates its own
189
+ declaration skips the scheduled run with a logged error instead of starting it
190
+ with a half-applied set.
191
+
117
192
  If the sync provider needs bootstrap credentials, mappings, locales, channels, or other default sync settings after a fresh install, implement a provider-owned env preset flow:
118
193
 
119
194
  - read env vars in the provider package
@@ -55,6 +55,7 @@ export async function GET(req: Request) {
55
55
  runMode: adapter.runMode ?? 'generic',
56
56
  canStartRun: adapter.runMode !== 'provider',
57
57
  supportedEntities: adapter.supportedEntities,
58
+ runParameters: adapter.runParameters ?? [],
58
59
  hasCredentials: Boolean(credentials),
59
60
  isEnabled,
60
61
  settingsPath: `/backend/integrations/${encodeURIComponent(integration.id)}`,
@@ -10,6 +10,7 @@ import type { SyncRunService } from '../lib/sync-run-service'
10
10
  import { runSyncSchema } from '../data/validators'
11
11
  import { startDataSyncRun } from '../lib/start-run'
12
12
  import { getDataSyncAdapter } from '../lib/adapter-registry'
13
+ import { normalizeRunParameters } from '../lib/run-parameters'
13
14
  import { resolveStartCursor } from '../lib/start-cursor'
14
15
  import {
15
16
  runCrudMutationGuardAfterSuccess,
@@ -78,6 +79,19 @@ export async function POST(req: Request) {
78
79
  return NextResponse.json({ error: 'Unsupported entity type for this integration' }, { status: 422 })
79
80
  }
80
81
 
82
+ const normalizedParameters = normalizeRunParameters(
83
+ adapter.runParameters,
84
+ parsed.data.direction,
85
+ parsed.data.parameters,
86
+ parsed.data.entityType,
87
+ )
88
+ if (!normalizedParameters.ok) {
89
+ return NextResponse.json(
90
+ { error: 'Invalid run parameters', details: { parameters: normalizedParameters.errors } },
91
+ { status: 422 },
92
+ )
93
+ }
94
+
81
95
  const integrationEnabled = await integrationStateService.isEnabled(parsed.data.integrationId, scope)
82
96
  if (!integrationEnabled) {
83
97
  return NextResponse.json({ error: 'Integration is disabled' }, { status: 409 })
@@ -133,6 +147,9 @@ export async function POST(req: Request) {
133
147
  cursor,
134
148
  triggeredBy: parsed.data.triggeredBy ?? auth.sub,
135
149
  batchSize: parsed.data.batchSize,
150
+ parameters: Object.keys(normalizedParameters.values).length > 0
151
+ ? normalizedParameters.values
152
+ : null,
136
153
  },
137
154
  })
138
155
 
@@ -8,6 +8,7 @@ import type { ProgressService } from '../../../../progress/lib/progressService'
8
8
  import type { SyncRunService } from '../../../lib/sync-run-service'
9
9
  import { retrySyncSchema } from '../../../data/validators'
10
10
  import { startDataSyncRun } from '../../../lib/start-run'
11
+ import { normalizeRunParameters } from '../../../lib/run-parameters'
11
12
  import { resolveAdapterForIntegration, resolveStartCursor } from '../../../lib/start-cursor'
12
13
  import {
13
14
  runCrudMutationGuardAfterSuccess,
@@ -88,11 +89,40 @@ export async function POST(req: Request, ctx: { params?: Promise<{ id?: string }
88
89
  return NextResponse.json(guardResult.body, { status: guardResult.status })
89
90
  }
90
91
 
92
+ // A retry replays the stored parameters, but the adapter's declaration may
93
+ // have moved on since the original run — a parameter dropped, a bound
94
+ // tightened, a select option removed, a scope narrowed. Re-normalize against
95
+ // the current declaration so an adapter never receives a set the run API
96
+ // would reject today. Undeclared keys fall away silently; values that are now
97
+ // invalid stop the retry, because the operator has no form here to fix them.
98
+ const retryAdapter = resolveAdapterForIntegration(previous.integrationId)
99
+ const normalizedParameters = normalizeRunParameters(
100
+ retryAdapter?.runParameters,
101
+ previous.direction,
102
+ previous.parameters ?? null,
103
+ previous.entityType,
104
+ )
105
+ if (!normalizedParameters.ok) {
106
+ return NextResponse.json(
107
+ {
108
+ error: 'Stored run parameters are no longer valid for this integration. Start a new run from the Data Sync dashboard.',
109
+ // Machine-readable so the dashboard can render the way out in the
110
+ // operator's language; the English sentence stays for non-UI callers.
111
+ code: 'parametersStale',
112
+ details: { parameters: normalizedParameters.errors },
113
+ },
114
+ { status: 422 },
115
+ )
116
+ }
117
+ const retryParameters = Object.keys(normalizedParameters.values).length > 0
118
+ ? normalizedParameters.values
119
+ : null
120
+
91
121
  const cursor = parsedBody.data.fromBeginning
92
122
  ? null
93
123
  : previous.cursor ?? await resolveStartCursor({
94
124
  syncRunService,
95
- adapter: resolveAdapterForIntegration(previous.integrationId),
125
+ adapter: retryAdapter,
96
126
  integrationId: previous.integrationId,
97
127
  entityType: previous.entityType,
98
128
  direction: previous.direction,
@@ -113,6 +143,7 @@ export async function POST(req: Request, ctx: { params?: Promise<{ id?: string }
113
143
  cursor,
114
144
  triggeredBy: auth.sub,
115
145
  batchSize: 100,
146
+ parameters: retryParameters,
116
147
  progressJob: {
117
148
  name: `Retry data sync ${previous.integrationId} — ${previous.entityType}`,
118
149
  },
@@ -69,6 +69,7 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
69
69
  batchesCompleted: run.batchesCompleted,
70
70
  lastError: run.lastError ?? null,
71
71
  progressJobId: run.progressJobId ?? null,
72
+ parameters: run.parameters ?? null,
72
73
  progressJob: progressJob
73
74
  ? {
74
75
  id: progressJob.id,
@@ -44,6 +44,18 @@ import {
44
44
  ShieldCheck,
45
45
  } from 'lucide-react'
46
46
  import { getSyncRunStatusVariant, getSyncSummaryVariant } from '../../lib/syncRunStatus'
47
+ import type { RunParameter } from '../../lib/adapter'
48
+ import { getApplicableRunParameters } from '../../lib/run-parameters'
49
+ import {
50
+ RunParameterFields,
51
+ buildDefaultRunParameterValues,
52
+ buildRetryFailureMessage,
53
+ buildRunFailureMessage,
54
+ buildRunParametersPayload,
55
+ type RetryFailureBody,
56
+ type RunFailureBody,
57
+ type RunParameterFormValue,
58
+ } from '../../components/RunParameterFields'
47
59
 
48
60
  type SyncRunRow = {
49
61
  id: string
@@ -73,6 +85,7 @@ type SyncOption = {
73
85
  runMode?: 'generic' | 'provider'
74
86
  canStartRun?: boolean
75
87
  supportedEntities: string[]
88
+ runParameters?: RunParameter[]
76
89
  hasCredentials: boolean
77
90
  isEnabled: boolean
78
91
  settingsPath: string
@@ -149,6 +162,7 @@ export default function SyncRunsDashboardPage() {
149
162
  const [selectedDirection, setSelectedDirection] = React.useState<'import' | 'export'>('import')
150
163
  const [batchSize, setBatchSize] = React.useState('100')
151
164
  const [fullSync, setFullSync] = React.useState(false)
165
+ const [paramValues, setParamValues] = React.useState<Record<string, RunParameterFormValue>>({})
152
166
  const [scheduleEditor, setScheduleEditor] = React.useState<SyncScheduleEditorState>(() => buildDefaultScheduleState(''))
153
167
  const [isLoadingSchedule, setIsLoadingSchedule] = React.useState(false)
154
168
  const [isSavingSchedule, setIsSavingSchedule] = React.useState(false)
@@ -231,6 +245,27 @@ export default function SyncRunsDashboardPage() {
231
245
  [selectedIntegration],
232
246
  )
233
247
 
248
+ const runParameters = React.useMemo(
249
+ () => getApplicableRunParameters(
250
+ selectedIntegration?.runParameters,
251
+ selectedDirection,
252
+ // Pass the state through as-is. Before an entity is chosen it is '',
253
+ // which matches no `entityType` and so hides scoped parameters — the
254
+ // wanted outcome. Mapping '' to `undefined` would mean "skip entity
255
+ // scoping" and show every scoped parameter instead.
256
+ selectedEntityType,
257
+ ),
258
+ [selectedIntegration, selectedDirection, selectedEntityType],
259
+ )
260
+
261
+ React.useEffect(() => {
262
+ setParamValues(buildDefaultRunParameterValues(runParameters))
263
+ }, [runParameters])
264
+
265
+ const updateParamValue = React.useCallback((key: string, value: RunParameterFormValue) => {
266
+ setParamValues((current) => ({ ...current, [key]: value }))
267
+ }, [])
268
+
234
269
  React.useEffect(() => {
235
270
  if (!selectedIntegration) {
236
271
  setSelectedEntityType('')
@@ -325,7 +360,7 @@ export default function SyncRunsDashboardPage() {
325
360
  flash(t('data_sync.runs.detail.retrySuccess'), 'success')
326
361
  setReloadToken((token) => token + 1)
327
362
  } else {
328
- flash(t('data_sync.runs.detail.retryError'), 'error')
363
+ flash(buildRetryFailureMessage(call.result as RetryFailureBody | null, t), 'error')
329
364
  }
330
365
  }, [t])
331
366
 
@@ -352,27 +387,25 @@ export default function SyncRunsDashboardPage() {
352
387
  return
353
388
  }
354
389
 
390
+ const parameters = buildRunParametersPayload(runParameters, paramValues)
391
+ const requestBody: Record<string, unknown> = {
392
+ integrationId: selectedIntegration.integrationId,
393
+ entityType: selectedEntityType,
394
+ direction: selectedDirection,
395
+ batchSize: parsedBatchSize,
396
+ fullSync,
397
+ }
398
+ if (runParameters.length > 0) requestBody.parameters = parameters
399
+
355
400
  try {
356
401
  const call = await runMutation({
357
402
  // optimistic-lock-exempt: starts a new sync run (create), not a concurrent record edit
358
403
  operation: () => apiCall<{ id: string }>('/api/data_sync/run', {
359
404
  method: 'POST',
360
405
  headers: { 'Content-Type': 'application/json' },
361
- body: JSON.stringify({
362
- integrationId: selectedIntegration.integrationId,
363
- entityType: selectedEntityType,
364
- direction: selectedDirection,
365
- batchSize: parsedBatchSize,
366
- fullSync,
367
- }),
406
+ body: JSON.stringify(requestBody),
368
407
  }, { fallback: null }),
369
- mutationPayload: {
370
- integrationId: selectedIntegration.integrationId,
371
- entityType: selectedEntityType,
372
- direction: selectedDirection,
373
- batchSize: parsedBatchSize,
374
- fullSync,
375
- },
408
+ mutationPayload: requestBody,
376
409
  context: {
377
410
  operation: 'create',
378
411
  actionId: 'start-sync-run',
@@ -381,7 +414,11 @@ export default function SyncRunsDashboardPage() {
381
414
  })
382
415
 
383
416
  if (!call.ok || !call.result?.id) {
384
- flash((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.start.error', 'Failed to start sync run'), 'error')
417
+ flash(buildRunFailureMessage(
418
+ call.result as RunFailureBody | null,
419
+ t('data_sync.dashboard.start.error', 'Failed to start sync run'),
420
+ t,
421
+ ), 'error')
385
422
  return
386
423
  }
387
424
 
@@ -392,7 +429,7 @@ export default function SyncRunsDashboardPage() {
392
429
  const message = error instanceof Error ? error.message : t('data_sync.dashboard.start.error', 'Failed to start sync run')
393
430
  flash(message, 'error')
394
431
  }
395
- }, [batchSize, fullSync, router, runMutation, selectedDirection, selectedEntityType, selectedIntegration, t])
432
+ }, [batchSize, fullSync, paramValues, router, runMutation, runParameters, selectedDirection, selectedEntityType, selectedIntegration, t])
396
433
 
397
434
  const handleSaveSchedule = React.useCallback(async () => {
398
435
  if (!selectedIntegration || !selectedEntityType) return
@@ -784,6 +821,25 @@ export default function SyncRunsDashboardPage() {
784
821
  </div>
785
822
  </div>
786
823
 
824
+ {runParameters.length > 0 ? (
825
+ <div className="mt-4 space-y-3">
826
+ <div className="flex items-center gap-2">
827
+ <Settings2 className="size-4 text-muted-foreground" />
828
+ <h4 className="text-sm font-semibold">
829
+ {t('data_sync.dashboard.start.parameters', 'Run parameters')}
830
+ </h4>
831
+ </div>
832
+ <p className="text-xs text-muted-foreground">
833
+ {t('data_sync.dashboard.start.parametersHelp', 'Optional values this integration accepts for the manual run.')}
834
+ </p>
835
+ <RunParameterFields
836
+ params={runParameters}
837
+ values={paramValues}
838
+ onChange={updateParamValue}
839
+ />
840
+ </div>
841
+ ) : null}
842
+
787
843
  <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
788
844
  <p className="text-xs text-muted-foreground">
789
845
  {t('data_sync.dashboard.start.runNowFootnote', 'Manual runs show progress immediately and land on the run detail page after launch.')}
@@ -19,6 +19,17 @@ import { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato
19
19
  import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
20
20
  import { RotateCcw, XCircle } from 'lucide-react'
21
21
  import { getSyncRunStatusVariant } from '../../../../lib/syncRunStatus'
22
+ import {
23
+ buildRetryFailureMessage,
24
+ resolveRunParameterText,
25
+ type RetryFailureBody,
26
+ } from '../../../../components/RunParameterFields'
27
+
28
+ type RunParameterDeclaration = {
29
+ key: string
30
+ label?: string
31
+ labelKey?: string
32
+ }
22
33
 
23
34
  type SyncRunDetail = {
24
35
  id: string
@@ -33,6 +44,7 @@ type SyncRunDetail = {
33
44
  batchesCompleted: number
34
45
  lastError: string | null
35
46
  progressJobId: string | null
47
+ parameters: Record<string, unknown> | null
36
48
  progressJob: {
37
49
  id: string
38
50
  status: string
@@ -111,6 +123,11 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
111
123
  const [logsTotal, setLogsTotal] = React.useState(0)
112
124
  const [logsPage, setLogsPage] = React.useState(1)
113
125
  const logsPageRef = React.useRef(1)
126
+ const [parameterLabels, setParameterLabels] = React.useState<Record<string, string>>({})
127
+ // Declarations cannot change between two refreshes of the same run, so the
128
+ // options list is fetched once per integration rather than on every progress
129
+ // event that re-reads the run.
130
+ const parameterLabelsIntegrationRef = React.useRef<string | null>(null)
114
131
 
115
132
  const resolveCurrentRunId = React.useCallback(() => {
116
133
  return runId ?? (
@@ -120,6 +137,26 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
120
137
  )
121
138
  }, [runId])
122
139
 
140
+ // The run row stores machine keys. Resolve the adapter's declared labels so a
141
+ // past run reads as "Start id" rather than "startId"; keys the adapter no
142
+ // longer declares keep their raw form, which keeps historical runs readable.
143
+ const loadParameterLabels = React.useCallback(async (integrationId: string) => {
144
+ if (parameterLabelsIntegrationRef.current === integrationId) return
145
+ parameterLabelsIntegrationRef.current = integrationId
146
+ const call = await apiCall<{ items?: Array<{ integrationId: string; runParameters?: RunParameterDeclaration[] }> }>(
147
+ '/api/data_sync/options',
148
+ undefined,
149
+ { fallback: { items: [] } },
150
+ )
151
+ const declared = (call.result?.items ?? []).find((item) => item.integrationId === integrationId)?.runParameters ?? []
152
+ const labels: Record<string, string> = {}
153
+ for (const param of declared) {
154
+ const resolved = resolveRunParameterText(t, param.labelKey, param.label)
155
+ if (resolved) labels[param.key] = resolved
156
+ }
157
+ setParameterLabels(labels)
158
+ }, [t])
159
+
123
160
  const loadRun = React.useCallback(async () => {
124
161
  const currentRunId = resolveCurrentRunId()
125
162
  if (!currentRunId) {
@@ -144,7 +181,10 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
144
181
  }
145
182
  setRun(call.result)
146
183
  setIsLoading(false)
147
- }, [resolveCurrentRunId, t])
184
+ if (call.result.parameters && Object.keys(call.result.parameters).length > 0) {
185
+ void loadParameterLabels(call.result.integrationId)
186
+ }
187
+ }, [loadParameterLabels, resolveCurrentRunId, t])
148
188
 
149
189
  const loadLogs = React.useCallback(async (page?: number) => {
150
190
  const currentRunId = resolveCurrentRunId()
@@ -268,7 +308,7 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
268
308
  flash(t('data_sync.runs.detail.retrySuccess'), 'success')
269
309
  router.push(`/backend/data-sync/runs/${encodeURIComponent(call.result.id)}`)
270
310
  } else {
271
- flash(t('data_sync.runs.detail.retryError'), 'error')
311
+ flash(buildRetryFailureMessage(call.result as RetryFailureBody | null, t), 'error')
272
312
  }
273
313
  }, [resolveCurrentRunId, router, runMutation, t])
274
314
 
@@ -404,6 +444,26 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
404
444
  </Card>
405
445
  </div>
406
446
 
447
+ {run.parameters && Object.keys(run.parameters).length > 0 ? (
448
+ <Card>
449
+ <CardHeader>
450
+ <CardTitle>{t('data_sync.runs.detail.parameters', 'Run parameters')}</CardTitle>
451
+ </CardHeader>
452
+ <CardContent>
453
+ <dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
454
+ {Object.entries(run.parameters).map(([key, value]) => (
455
+ <div key={key} className="flex items-center justify-between gap-3 rounded-md border bg-card px-3 py-2 text-sm">
456
+ <dt className="font-medium text-muted-foreground">{parameterLabels[key] ?? key}</dt>
457
+ <dd className="font-mono text-foreground">
458
+ {typeof value === 'boolean' ? String(value) : String(value ?? '')}
459
+ </dd>
460
+ </div>
461
+ ))}
462
+ </dl>
463
+ </CardContent>
464
+ </Card>
465
+ ) : null}
466
+
407
467
  {run.lastError && (
408
468
  <Card className="border-status-error-border bg-status-error-bg">
409
469
  <CardHeader>