mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
✨ feat(data-sync): 建立同步任务模型与运行网关
- 定义任务、映射、调度、预检和运行记录前端模型 - 接入 Wails 元数据、审批、生命周期、检查点与错误行 API - 严格解码后端协议并补齐模型和网关回归测试
This commit is contained in:
167
frontend/src/components/data-sync/gateway.test.ts
Normal file
167
frontend/src/components/data-sync/gateway.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createStaticDataSyncWorkbenchGateway } from './gateway';
|
||||
import {
|
||||
createDataSyncTableMapping,
|
||||
createDataSyncTaskDraft,
|
||||
reviseDataSyncTask,
|
||||
} from './model';
|
||||
|
||||
const configuredTask = () => {
|
||||
const draft = createDataSyncTaskDraft({
|
||||
id: 'static-task',
|
||||
kind: 'migration',
|
||||
now: '2026-08-08T00:00:00.000Z',
|
||||
});
|
||||
return reviseDataSyncTask(draft, {
|
||||
name: 'Static migration',
|
||||
lifecycle: 'ready',
|
||||
source: { ...draft.source, connectionId: 'source' },
|
||||
target: { ...draft.target, connectionId: 'target' },
|
||||
mappings: [createDataSyncTableMapping('map-1', 'source.orders', 'target.orders')],
|
||||
});
|
||||
};
|
||||
|
||||
describe('static data sync workbench gateway', () => {
|
||||
it('persists task references in memory and binds preflight to the task revision', async () => {
|
||||
const task = configuredTask();
|
||||
const gateway = createStaticDataSyncWorkbenchGateway({
|
||||
tasks: [task],
|
||||
capabilities: {
|
||||
[task.id]: {
|
||||
level: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
supportsCdc: false,
|
||||
},
|
||||
},
|
||||
now: () => '2026-08-08T01:00:00.000Z',
|
||||
});
|
||||
|
||||
const preflight = await gateway.preflightTask(task);
|
||||
expect(preflight).toMatchObject({
|
||||
taskId: task.id,
|
||||
taskRevision: task.revision,
|
||||
status: 'passed',
|
||||
issues: [],
|
||||
definitionHash: `static:${task.id}:${task.revision}`,
|
||||
approvalRequired: false,
|
||||
});
|
||||
|
||||
const run = await gateway.startTask(task, preflight);
|
||||
expect(run).toMatchObject({
|
||||
taskId: task.id,
|
||||
status: 'queued',
|
||||
trigger: 'manual',
|
||||
});
|
||||
expect(await gateway.listRuns(task.id)).toHaveLength(1);
|
||||
|
||||
const renamed = reviseDataSyncTask(task, { name: 'Renamed migration' });
|
||||
await gateway.saveTask(renamed);
|
||||
expect(await gateway.listTasks()).toContainEqual(renamed);
|
||||
});
|
||||
|
||||
it('fails closed with an explicit warning when no backend capability is injected', async () => {
|
||||
const task = configuredTask();
|
||||
const gateway = createStaticDataSyncWorkbenchGateway({ tasks: [task] });
|
||||
|
||||
expect(await gateway.resolveCapability(task)).toMatchObject({
|
||||
level: 'unknown',
|
||||
canExecute: false,
|
||||
});
|
||||
expect((await gateway.preflightTask(task)).issues.map((issue) => issue.code)).toContain(
|
||||
'capability_unverified',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes credential-free metadata fixtures across connection, object, and field levels', async () => {
|
||||
const gateway = createStaticDataSyncWorkbenchGateway();
|
||||
const connections = await gateway.listSavedConnections();
|
||||
const source = connections.find((item) => item.id === 'fixture-mysql-sales')!;
|
||||
|
||||
expect(Object.keys(source).sort()).toEqual([
|
||||
'id',
|
||||
'name',
|
||||
'readable',
|
||||
'type',
|
||||
'writable',
|
||||
]);
|
||||
expect(await gateway.listDatabases(source.id)).toEqual([{ name: 'sales' }]);
|
||||
|
||||
const endpoint = {
|
||||
connectionId: source.id,
|
||||
connectionName: source.name,
|
||||
type: source.type,
|
||||
database: 'sales',
|
||||
schema: '',
|
||||
};
|
||||
expect(await gateway.listObjects(endpoint)).toContainEqual({
|
||||
name: 'orders',
|
||||
kind: 'table',
|
||||
});
|
||||
expect(await gateway.listFields(endpoint, 'orders')).toContainEqual(
|
||||
expect.objectContaining({ name: 'id', key: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mint approval tokens and blocks an approval-required run', async () => {
|
||||
const task = configuredTask();
|
||||
const gateway = createStaticDataSyncWorkbenchGateway({
|
||||
tasks: [task],
|
||||
approvalRequiredByTask: { [task.id]: true },
|
||||
definitionHashByTask: { [task.id]: 'definition-hash' },
|
||||
});
|
||||
const preflight = await gateway.preflightTask(task);
|
||||
|
||||
expect(preflight).toMatchObject({
|
||||
approvalRequired: true,
|
||||
definitionHash: 'definition-hash',
|
||||
});
|
||||
await expect(gateway.startTask(task, preflight)).rejects.toThrow(
|
||||
'preflight is not current',
|
||||
);
|
||||
await expect(
|
||||
gateway.beginApproval(task, preflight),
|
||||
).rejects.toThrow('approval gateway is not configured');
|
||||
await expect(gateway.approveTask(task, preflight)).rejects.toThrow(
|
||||
'approval gateway is not configured',
|
||||
);
|
||||
});
|
||||
|
||||
it('resets checkpoints only for the current paused task revision', async () => {
|
||||
const ready = configuredTask();
|
||||
const paused = { ...ready, lifecycle: 'paused' as const };
|
||||
const checkpoint = {
|
||||
taskId: paused.id,
|
||||
runId: 'run-1',
|
||||
kind: 'watermark',
|
||||
phase: 'batch_committed',
|
||||
cursorPreview: '{"id":42}',
|
||||
updatedAt: '2026-08-08T00:30:00.000Z',
|
||||
};
|
||||
const gateway = createStaticDataSyncWorkbenchGateway({
|
||||
tasks: [paused],
|
||||
checkpointsByTask: { [paused.id]: checkpoint },
|
||||
now: () => '2026-08-08T01:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
gateway.resetCheckpoint(paused.id, paused.revision - 1),
|
||||
).rejects.toThrow('revision changed');
|
||||
const saved = await gateway.resetCheckpoint(paused.id, paused.revision);
|
||||
expect(saved).toMatchObject({
|
||||
id: paused.id,
|
||||
lifecycle: 'paused',
|
||||
revision: paused.revision + 1,
|
||||
});
|
||||
expect(await gateway.getCheckpoint(paused.id)).toBeNull();
|
||||
|
||||
const readyGateway = createStaticDataSyncWorkbenchGateway({
|
||||
tasks: [ready],
|
||||
checkpointsByTask: { [ready.id]: checkpoint },
|
||||
});
|
||||
await expect(
|
||||
readyGateway.resetCheckpoint(ready.id, ready.revision),
|
||||
).rejects.toThrow('requires a paused task');
|
||||
});
|
||||
});
|
||||
401
frontend/src/components/data-sync/gateway.ts
Normal file
401
frontend/src/components/data-sync/gateway.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
resolveDataSyncPreflightStatus,
|
||||
validateDataSyncTask,
|
||||
type DataSyncApprovalChallenge,
|
||||
type DataSyncApprovalGrant,
|
||||
type DataSyncCdcSourceStatus,
|
||||
type DataSyncCheckpointSummary,
|
||||
type DataSyncDatabaseMetadata,
|
||||
type DataSyncErrorRow,
|
||||
type DataSyncFieldMetadata,
|
||||
type DataSyncObjectMetadata,
|
||||
type DataSyncPreflightSnapshot,
|
||||
type DataSyncRouteCapability,
|
||||
type DataSyncRunRecord,
|
||||
type DataSyncSavedConnectionView,
|
||||
type DataSyncScheduleSummary,
|
||||
type DataSyncTaskDefinition,
|
||||
type DataSyncEndpointRef,
|
||||
type DataSyncValidationIssue,
|
||||
} from './model';
|
||||
|
||||
export interface DataSyncWorkbenchGateway {
|
||||
readonly capabilities: {
|
||||
/** Row retry is exposed only when the backend can replay captured payloads. */
|
||||
errorRowRetry: boolean;
|
||||
};
|
||||
/** Returns credential-free connection summaries only. */
|
||||
listSavedConnections(): Promise<DataSyncSavedConnectionView[]>;
|
||||
listDatabases(connectionId: string): Promise<DataSyncDatabaseMetadata[]>;
|
||||
listObjects(endpoint: DataSyncEndpointRef): Promise<DataSyncObjectMetadata[]>;
|
||||
listFields(
|
||||
endpoint: DataSyncEndpointRef,
|
||||
objectName: string,
|
||||
): Promise<DataSyncFieldMetadata[]>;
|
||||
listTasks(): Promise<DataSyncTaskDefinition[]>;
|
||||
saveTask(task: DataSyncTaskDefinition): Promise<DataSyncTaskDefinition>;
|
||||
resolveCapability(task: DataSyncTaskDefinition): Promise<DataSyncRouteCapability>;
|
||||
preflightTask(task: DataSyncTaskDefinition): Promise<DataSyncPreflightSnapshot>;
|
||||
beginApproval(
|
||||
task: DataSyncTaskDefinition,
|
||||
preflight: DataSyncPreflightSnapshot,
|
||||
): Promise<DataSyncApprovalChallenge>;
|
||||
approveTask(
|
||||
task: DataSyncTaskDefinition,
|
||||
preflight: DataSyncPreflightSnapshot,
|
||||
): Promise<DataSyncApprovalGrant>;
|
||||
startTask(
|
||||
task: DataSyncTaskDefinition,
|
||||
preflight: DataSyncPreflightSnapshot,
|
||||
): Promise<DataSyncRunRecord>;
|
||||
listRuns(taskId?: string): Promise<DataSyncRunRecord[]>;
|
||||
listErrorRows(runId: string): Promise<DataSyncErrorRow[]>;
|
||||
listSchedules(): Promise<DataSyncScheduleSummary[]>;
|
||||
listCdcAdapters(): Promise<string[]>;
|
||||
listCdcSources(): Promise<DataSyncCdcSourceStatus[]>;
|
||||
getCheckpoint(taskId: string): Promise<DataSyncCheckpointSummary | null>;
|
||||
resetCheckpoint(
|
||||
taskId: string,
|
||||
expectedJobRevision: number,
|
||||
): Promise<DataSyncTaskDefinition>;
|
||||
cancelRun(runId: string): Promise<void>;
|
||||
resumeRun(runId: string): Promise<DataSyncRunRecord>;
|
||||
retryRun(runId: string): Promise<DataSyncRunRecord>;
|
||||
discardErrorRow(errorRowId: string): Promise<void>;
|
||||
retryErrorRow(errorRowId: string): Promise<DataSyncErrorRow>;
|
||||
}
|
||||
|
||||
export type StaticDataSyncGatewayFixtures = {
|
||||
savedConnections?: DataSyncSavedConnectionView[];
|
||||
databasesByConnection?: Record<string, DataSyncDatabaseMetadata[]>;
|
||||
objectsByEndpoint?: Record<string, DataSyncObjectMetadata[]>;
|
||||
fieldsByObject?: Record<string, DataSyncFieldMetadata[]>;
|
||||
tasks?: DataSyncTaskDefinition[];
|
||||
capabilities?: Record<string, DataSyncRouteCapability>;
|
||||
runs?: DataSyncRunRecord[];
|
||||
errorRowsByRun?: Record<string, DataSyncErrorRow[]>;
|
||||
schedules?: DataSyncScheduleSummary[];
|
||||
cdcSources?: DataSyncCdcSourceStatus[];
|
||||
cdcAdapters?: string[];
|
||||
checkpointsByTask?: Record<string, DataSyncCheckpointSummary>;
|
||||
extraPreflightIssues?: DataSyncValidationIssue[];
|
||||
approvalRequiredByTask?: Record<string, boolean>;
|
||||
definitionHashByTask?: Record<string, string>;
|
||||
now?: () => string;
|
||||
};
|
||||
|
||||
const DEFAULT_SAVED_CONNECTIONS: DataSyncSavedConnectionView[] = [
|
||||
{
|
||||
id: 'fixture-mysql-sales',
|
||||
name: 'MySQL Sales',
|
||||
type: 'mysql',
|
||||
readable: true,
|
||||
writable: true,
|
||||
},
|
||||
{
|
||||
id: 'fixture-postgres-analytics',
|
||||
name: 'PostgreSQL Analytics',
|
||||
type: 'postgresql',
|
||||
readable: true,
|
||||
writable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_DATABASES: Record<string, DataSyncDatabaseMetadata[]> = {
|
||||
'fixture-mysql-sales': [{ name: 'sales' }],
|
||||
'fixture-postgres-analytics': [{ name: 'analytics' }],
|
||||
};
|
||||
|
||||
export const dataSyncEndpointMetadataKey = (
|
||||
endpoint: Pick<DataSyncEndpointRef, 'connectionId' | 'database' | 'schema'>,
|
||||
): string =>
|
||||
[endpoint.connectionId, endpoint.database, endpoint.schema]
|
||||
.map((part) => encodeURIComponent(part.trim().toLowerCase()))
|
||||
.join('|');
|
||||
|
||||
export const dataSyncObjectMetadataKey = (
|
||||
endpoint: Pick<DataSyncEndpointRef, 'connectionId' | 'database' | 'schema'>,
|
||||
objectName: string,
|
||||
): string =>
|
||||
`${dataSyncEndpointMetadataKey(endpoint)}|${encodeURIComponent(
|
||||
objectName.trim().toLowerCase(),
|
||||
)}`;
|
||||
|
||||
const DEFAULT_OBJECTS: Record<string, DataSyncObjectMetadata[]> = {
|
||||
[dataSyncEndpointMetadataKey({
|
||||
connectionId: 'fixture-mysql-sales',
|
||||
database: 'sales',
|
||||
schema: '',
|
||||
})]: [
|
||||
{ name: 'orders', kind: 'table' },
|
||||
{ name: 'customers', kind: 'table' },
|
||||
],
|
||||
[dataSyncEndpointMetadataKey({
|
||||
connectionId: 'fixture-postgres-analytics',
|
||||
database: 'analytics',
|
||||
schema: '',
|
||||
})]: [
|
||||
{ name: 'fact_orders', kind: 'table' },
|
||||
{ name: 'dim_customers', kind: 'table' },
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_FIELDS: Record<string, DataSyncFieldMetadata[]> = {
|
||||
[dataSyncObjectMetadataKey(
|
||||
{
|
||||
connectionId: 'fixture-mysql-sales',
|
||||
database: 'sales',
|
||||
schema: '',
|
||||
},
|
||||
'orders',
|
||||
)]: [
|
||||
{ name: 'id', type: 'bigint', nullable: false, ordinal: 1, key: true },
|
||||
{ name: 'customer_id', type: 'bigint', nullable: false, ordinal: 2, key: false },
|
||||
{ name: 'amount', type: 'decimal(18,2)', nullable: false, ordinal: 3, key: false },
|
||||
{ name: 'created_at', type: 'datetime', nullable: false, ordinal: 4, key: false },
|
||||
],
|
||||
[dataSyncObjectMetadataKey(
|
||||
{
|
||||
connectionId: 'fixture-postgres-analytics',
|
||||
database: 'analytics',
|
||||
schema: '',
|
||||
},
|
||||
'fact_orders',
|
||||
)]: [
|
||||
{ name: 'id', type: 'int8', nullable: false, ordinal: 1, key: true },
|
||||
{ name: 'customer_id', type: 'int8', nullable: false, ordinal: 2, key: false },
|
||||
{ name: 'amount', type: 'numeric(18,2)', nullable: false, ordinal: 3, key: false },
|
||||
{ name: 'created_at', type: 'timestamptz', nullable: false, ordinal: 4, key: false },
|
||||
],
|
||||
};
|
||||
|
||||
const copy = <T,>(value: T): T => {
|
||||
if (typeof structuredClone === 'function') return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
};
|
||||
|
||||
const unresolvedCapability: DataSyncRouteCapability = {
|
||||
level: 'unknown',
|
||||
canExecute: false,
|
||||
supportsAutoCreate: false,
|
||||
supportsCdc: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Static adapter used until persisted task/run APIs are available.
|
||||
* It deliberately does not call Wails and stores only non-secret task references.
|
||||
*/
|
||||
export const createStaticDataSyncWorkbenchGateway = (
|
||||
fixtures: StaticDataSyncGatewayFixtures = {},
|
||||
): DataSyncWorkbenchGateway => {
|
||||
const taskMap = new Map(
|
||||
(fixtures.tasks || []).map((task) => [task.id, copy(task)] as const),
|
||||
);
|
||||
const runs = copy(fixtures.runs || []);
|
||||
const errors = copy(fixtures.errorRowsByRun || {});
|
||||
const schedules = copy(fixtures.schedules || []);
|
||||
const cdcSources = copy(fixtures.cdcSources || []);
|
||||
const cdcAdapters = copy(fixtures.cdcAdapters || ['mongodb-change-stream']);
|
||||
const checkpoints = copy(fixtures.checkpointsByTask || {});
|
||||
const savedConnections = copy(
|
||||
fixtures.savedConnections || DEFAULT_SAVED_CONNECTIONS,
|
||||
);
|
||||
const databases = copy(fixtures.databasesByConnection || DEFAULT_DATABASES);
|
||||
const objects = copy(fixtures.objectsByEndpoint || DEFAULT_OBJECTS);
|
||||
const fields = copy(fixtures.fieldsByObject || DEFAULT_FIELDS);
|
||||
const now = fixtures.now || (() => new Date().toISOString());
|
||||
|
||||
return {
|
||||
capabilities: { errorRowRetry: false },
|
||||
async listSavedConnections() {
|
||||
return savedConnections.map(copy);
|
||||
},
|
||||
async listDatabases(connectionId) {
|
||||
return (databases[connectionId] || []).map(copy);
|
||||
},
|
||||
async listObjects(endpoint) {
|
||||
const exactKey = dataSyncEndpointMetadataKey(endpoint);
|
||||
const withoutSchemaKey = dataSyncEndpointMetadataKey({
|
||||
...endpoint,
|
||||
schema: '',
|
||||
});
|
||||
return (objects[exactKey] || objects[withoutSchemaKey] || []).map(copy);
|
||||
},
|
||||
async listFields(endpoint, objectName) {
|
||||
const exactKey = dataSyncObjectMetadataKey(endpoint, objectName);
|
||||
const withoutSchemaKey = dataSyncObjectMetadataKey(
|
||||
{ ...endpoint, schema: '' },
|
||||
objectName,
|
||||
);
|
||||
return (fields[exactKey] || fields[withoutSchemaKey] || []).map(copy);
|
||||
},
|
||||
async listTasks() {
|
||||
return Array.from(taskMap.values()).map(copy);
|
||||
},
|
||||
async saveTask(task) {
|
||||
const saved = copy(task);
|
||||
taskMap.set(saved.id, saved);
|
||||
return copy(saved);
|
||||
},
|
||||
async resolveCapability(task) {
|
||||
const capability = fixtures.capabilities?.[task.id];
|
||||
return copy(capability || unresolvedCapability);
|
||||
},
|
||||
async preflightTask(task) {
|
||||
const capability = fixtures.capabilities?.[task.id];
|
||||
const issues = [
|
||||
...validateDataSyncTask(task),
|
||||
...(fixtures.extraPreflightIssues || []),
|
||||
];
|
||||
if (!capability || capability.level === 'unknown') {
|
||||
issues.push({
|
||||
id: 'capability_unverified',
|
||||
severity: 'warning',
|
||||
code: 'capability_unverified',
|
||||
stage: 'endpoints',
|
||||
});
|
||||
}
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskRevision: task.revision,
|
||||
status: resolveDataSyncPreflightStatus(issues),
|
||||
issues: copy(issues),
|
||||
definitionHash:
|
||||
fixtures.definitionHashByTask?.[task.id] ||
|
||||
`static:${task.id}:${task.revision}`,
|
||||
approvalRequired: Boolean(fixtures.approvalRequiredByTask?.[task.id]),
|
||||
approvalSatisfied: false,
|
||||
checkedAt: now(),
|
||||
};
|
||||
},
|
||||
async beginApproval() {
|
||||
throw new Error('data sync approval gateway is not configured');
|
||||
},
|
||||
async approveTask() {
|
||||
// The static adapter cannot mint production authorization tokens.
|
||||
throw new Error('data sync approval gateway is not configured');
|
||||
},
|
||||
async listRuns(taskId) {
|
||||
return runs
|
||||
.filter((run) => !taskId || run.taskId === taskId)
|
||||
.map(copy);
|
||||
},
|
||||
async startTask(task, preflight) {
|
||||
if (
|
||||
(task.lifecycle !== 'ready' && task.lifecycle !== 'enabled') ||
|
||||
preflight.taskId !== task.id ||
|
||||
preflight.taskRevision !== task.revision ||
|
||||
preflight.status === 'blocked' ||
|
||||
preflight.approvalRequired !== false
|
||||
) {
|
||||
throw new Error('data sync preflight is not current');
|
||||
}
|
||||
const startedAt = now();
|
||||
const run: DataSyncRunRecord = {
|
||||
id: `${task.id}:run:${startedAt}`,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
status: task.kind === 'cdc' ? 'streaming' : 'queued',
|
||||
trigger:
|
||||
task.trigger.mode === 'manual'
|
||||
? 'manual'
|
||||
: task.trigger.mode === 'continuous'
|
||||
? 'continuous'
|
||||
: 'schedule',
|
||||
attempt: 1,
|
||||
resumable: false,
|
||||
message: '',
|
||||
startedAt,
|
||||
finishedAt: '',
|
||||
rowsRead: 0,
|
||||
rowsWritten: 0,
|
||||
rowsFailed: 0,
|
||||
throughput: 0,
|
||||
checkpoint: '',
|
||||
};
|
||||
runs.unshift(run);
|
||||
return copy(run);
|
||||
},
|
||||
async listErrorRows(runId) {
|
||||
return (errors[runId] || []).map(copy);
|
||||
},
|
||||
async listSchedules() {
|
||||
return schedules.map(copy);
|
||||
},
|
||||
async listCdcAdapters() {
|
||||
return cdcAdapters.slice();
|
||||
},
|
||||
async listCdcSources() {
|
||||
return cdcSources.map(copy);
|
||||
},
|
||||
async getCheckpoint(taskId) {
|
||||
return checkpoints[taskId] ? copy(checkpoints[taskId]) : null;
|
||||
},
|
||||
async resetCheckpoint(taskId, expectedJobRevision) {
|
||||
const task = taskMap.get(taskId);
|
||||
if (!task) throw new Error('data sync task not found');
|
||||
if (task.revision !== expectedJobRevision) {
|
||||
throw new Error('data sync task revision changed');
|
||||
}
|
||||
if (task.lifecycle !== 'paused') {
|
||||
throw new Error('data sync checkpoint reset requires a paused task');
|
||||
}
|
||||
delete checkpoints[taskId];
|
||||
const saved = {
|
||||
...task,
|
||||
revision: task.revision + 1,
|
||||
updatedAt: now(),
|
||||
};
|
||||
taskMap.set(taskId, saved);
|
||||
return copy(saved);
|
||||
},
|
||||
async cancelRun(runId) {
|
||||
const run = runs.find((item) => item.id === runId);
|
||||
if (!run) throw new Error('data sync run not found');
|
||||
run.status = 'cancelling';
|
||||
},
|
||||
async resumeRun(runId) {
|
||||
const previous = runs.find((item) => item.id === runId);
|
||||
if (!previous) throw new Error('data sync run not found');
|
||||
const resumed = {
|
||||
...previous,
|
||||
id: `${previous.id}:resume:${now()}`,
|
||||
status: 'queued' as const,
|
||||
trigger: 'resume' as const,
|
||||
attempt: previous.attempt + 1,
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
};
|
||||
runs.unshift(resumed);
|
||||
return copy(resumed);
|
||||
},
|
||||
async retryRun(runId) {
|
||||
const previous = runs.find((item) => item.id === runId);
|
||||
if (!previous) throw new Error('data sync run not found');
|
||||
const retried = {
|
||||
...previous,
|
||||
id: `${previous.id}:retry:${now()}`,
|
||||
status: 'queued' as const,
|
||||
trigger: 'retry' as const,
|
||||
attempt: previous.attempt + 1,
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
};
|
||||
runs.unshift(retried);
|
||||
return copy(retried);
|
||||
},
|
||||
async discardErrorRow(errorRowId) {
|
||||
for (const rows of Object.values(errors)) {
|
||||
const row = rows.find((item) => item.id === errorRowId);
|
||||
if (row) {
|
||||
row.status = 'discarded';
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('data sync error row not found');
|
||||
},
|
||||
async retryErrorRow() {
|
||||
throw new Error('data sync error row retry is not supported');
|
||||
},
|
||||
};
|
||||
};
|
||||
324
frontend/src/components/data-sync/model.test.ts
Normal file
324
frontend/src/components/data-sync/model.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
autoMatchDataSyncFields,
|
||||
canUseDataSyncRowErrorIsolation,
|
||||
canStartDataSyncTask,
|
||||
buildDataSyncMappingsFromSelection,
|
||||
createDataSyncTableMapping,
|
||||
createDataSyncTaskDraft,
|
||||
isDataSyncPreflightCurrent,
|
||||
resolveDataSyncPreflightStatus,
|
||||
reviseDataSyncTask,
|
||||
validateDataSyncTask,
|
||||
type DataSyncPreflightSnapshot,
|
||||
} from './model';
|
||||
|
||||
describe('data sync task model', () => {
|
||||
it('creates versioned defaults for compare and CDC tasks', () => {
|
||||
const compare = createDataSyncTaskDraft({
|
||||
id: 'compare-1',
|
||||
kind: 'compare',
|
||||
compareMode: 'schema',
|
||||
now: '2026-08-08T00:00:00.000Z',
|
||||
});
|
||||
const cdc = createDataSyncTaskDraft({
|
||||
id: 'cdc-1',
|
||||
kind: 'cdc',
|
||||
now: '2026-08-08T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(compare).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
compareMode: 'schema',
|
||||
delivery: { writeMode: 'none' },
|
||||
trigger: { mode: 'manual' },
|
||||
incremental: { mode: 'snapshot' },
|
||||
});
|
||||
expect(cdc).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
delivery: { writeMode: 'upsert' },
|
||||
trigger: { mode: 'continuous' },
|
||||
incremental: {
|
||||
mode: 'cdc',
|
||||
initialSnapshot: false,
|
||||
startPosition: 'latest',
|
||||
adapter: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('increments revisions and makes an older preflight stale', () => {
|
||||
const task = createDataSyncTaskDraft({
|
||||
id: 'task-1',
|
||||
kind: 'migration',
|
||||
now: '2026-08-08T00:00:00.000Z',
|
||||
});
|
||||
const snapshot: DataSyncPreflightSnapshot = {
|
||||
taskId: task.id,
|
||||
taskRevision: task.revision,
|
||||
status: 'passed',
|
||||
issues: [],
|
||||
definitionHash: 'hash-1',
|
||||
approvalRequired: false,
|
||||
approvalSatisfied: false,
|
||||
checkedAt: '2026-08-08T00:01:00.000Z',
|
||||
};
|
||||
const revised = reviseDataSyncTask(
|
||||
task,
|
||||
{ name: 'Customer migration' },
|
||||
'2026-08-08T00:02:00.000Z',
|
||||
);
|
||||
|
||||
expect(revised.revision).toBe(2);
|
||||
expect(revised.createdAt).toBe(task.createdAt);
|
||||
expect(revised.updatedAt).toBe('2026-08-08T00:02:00.000Z');
|
||||
expect(isDataSyncPreflightCurrent(task, snapshot)).toBe(true);
|
||||
expect(isDataSyncPreflightCurrent(revised, snapshot)).toBe(false);
|
||||
expect(canStartDataSyncTask(revised, snapshot)).toBe(false);
|
||||
expect(canStartDataSyncTask(task, { ...snapshot, approvalRequired: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports endpoint, mapping, key, and delivery blockers by stage', () => {
|
||||
const task = createDataSyncTaskDraft({ id: 'task-2', kind: 'reconcile' });
|
||||
const issues = validateDataSyncTask(task);
|
||||
|
||||
expect(issues.map((item) => item.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'task_name_required',
|
||||
'source_connection_required',
|
||||
'target_connection_required',
|
||||
'mapping_required',
|
||||
]),
|
||||
);
|
||||
expect(issues).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'source_object_required' }),
|
||||
expect.objectContaining({ code: 'target_object_required' }),
|
||||
]),
|
||||
);
|
||||
expect(resolveDataSyncPreflightStatus(issues)).toBe('blocked');
|
||||
});
|
||||
|
||||
it('builds multiple mappings with safe same-name target suggestions', () => {
|
||||
const existing = [createDataSyncTableMapping('empty-row')];
|
||||
const migration = buildDataSyncMappingsFromSelection({
|
||||
taskId: 'migration-1',
|
||||
taskKind: 'migration',
|
||||
sourceNames: ['sales.Orders', 'customers', 'CUSTOMERS'],
|
||||
targetObjects: [
|
||||
{ name: 'orders', kind: 'table' },
|
||||
{ name: 'archive', kind: 'table' },
|
||||
],
|
||||
existingMappings: existing,
|
||||
keyColumnsBySource: { 'sales.orders': ['id'] },
|
||||
});
|
||||
|
||||
expect(migration).toHaveLength(2);
|
||||
expect(migration[0]).toMatchObject({
|
||||
sourceObject: 'sales.Orders',
|
||||
targetObject: 'orders',
|
||||
targetMode: 'existing_only',
|
||||
keyColumns: ['id'],
|
||||
});
|
||||
expect(migration[1]).toMatchObject({
|
||||
sourceObject: 'customers',
|
||||
targetObject: 'customers',
|
||||
targetMode: 'create_or_reuse',
|
||||
});
|
||||
|
||||
const reconcile = buildDataSyncMappingsFromSelection({
|
||||
taskId: 'reconcile-1',
|
||||
taskKind: 'reconcile',
|
||||
sourceNames: ['missing_target'],
|
||||
targetObjects: [],
|
||||
existingMappings: [],
|
||||
});
|
||||
expect(reconcile[0]).toMatchObject({
|
||||
sourceObject: 'missing_target',
|
||||
targetObject: '',
|
||||
targetMode: 'existing_only',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a complete reconcile task and detects duplicate targets', () => {
|
||||
const base = createDataSyncTaskDraft({ id: 'task-3', kind: 'reconcile' });
|
||||
const first = {
|
||||
...createDataSyncTableMapping('map-1', 'sales.orders', 'ods.orders'),
|
||||
keyColumns: ['id'],
|
||||
};
|
||||
const configured = reviseDataSyncTask(base, {
|
||||
name: 'Orders sync',
|
||||
source: { ...base.source, connectionId: 'mysql-prod' },
|
||||
target: { ...base.target, connectionId: 'pg-warehouse' },
|
||||
mappings: [first],
|
||||
});
|
||||
|
||||
expect(validateDataSyncTask(configured)).toEqual([]);
|
||||
|
||||
const duplicate = reviseDataSyncTask(configured, {
|
||||
mappings: [
|
||||
first,
|
||||
{
|
||||
...createDataSyncTableMapping('map-2', 'sales.order_lines', 'ODS.ORDERS'),
|
||||
keyColumns: ['id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(validateDataSyncTask(duplicate).map((item) => item.code)).toContain(
|
||||
'duplicate_target_object',
|
||||
);
|
||||
|
||||
const duplicateSource = reviseDataSyncTask(configured, {
|
||||
mappings: [
|
||||
first,
|
||||
{
|
||||
...createDataSyncTableMapping('map-3', 'SALES.ORDERS', 'ods.orders_copy'),
|
||||
keyColumns: ['id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(validateDataSyncTask(duplicateSource).map((item) => item.code)).toContain(
|
||||
'duplicate_source_object',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires Cron and watermark configuration without weakening CDC invariants', () => {
|
||||
const base = createDataSyncTaskDraft({ id: 'task-4', kind: 'cdc' });
|
||||
const invalid = reviseDataSyncTask(base, {
|
||||
trigger: { mode: 'cron', expression: '', timezone: '', overlap: 'skip' },
|
||||
incremental: {
|
||||
mode: 'watermark',
|
||||
column: '',
|
||||
tieBreaker: '',
|
||||
overlapWindowMs: 0,
|
||||
},
|
||||
});
|
||||
const codes = validateDataSyncTask(invalid).map((item) => item.code);
|
||||
|
||||
expect(codes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'cron_expression_required',
|
||||
'timezone_required',
|
||||
'watermark_column_required',
|
||||
'cdc_incremental_required',
|
||||
'cdc_trigger_required',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-matches field names case-insensitively and keeps existing transforms', () => {
|
||||
const matched = autoMatchDataSyncFields(
|
||||
'orders-map',
|
||||
[
|
||||
{ name: 'ID', type: 'bigint', nullable: false, ordinal: 1, key: true },
|
||||
{ name: 'amount', type: 'decimal', nullable: false, ordinal: 2, key: false },
|
||||
{ name: 'source_only', type: 'text', nullable: true, ordinal: 3, key: false },
|
||||
],
|
||||
[
|
||||
{ name: 'id', type: 'int8', nullable: false, ordinal: 1, key: true },
|
||||
{ name: 'AMOUNT', type: 'numeric', nullable: true, ordinal: 2, key: false },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'keep-transform',
|
||||
sourceField: 'amount',
|
||||
targetField: 'amount',
|
||||
sourceType: 'old',
|
||||
targetType: 'old',
|
||||
transform: 'upper',
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(matched).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceField: 'ID',
|
||||
targetField: 'id',
|
||||
sourceType: 'bigint',
|
||||
targetType: 'int8',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'keep-transform',
|
||||
sourceField: 'amount',
|
||||
targetField: 'AMOUNT',
|
||||
transform: 'upper',
|
||||
nullable: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('enables row isolation only for CDC or safe data-only atomic SQL routes', () => {
|
||||
const base = createDataSyncTaskDraft({ id: 'row-errors', kind: 'reconcile' });
|
||||
const safe = reviseDataSyncTask(base, {
|
||||
name: 'Safe row isolation',
|
||||
source: { ...base.source, connectionId: 'source', type: 'mysql' },
|
||||
target: {
|
||||
...base.target,
|
||||
connectionId: 'target',
|
||||
type: 'postgresql',
|
||||
},
|
||||
mappings: [
|
||||
{
|
||||
...createDataSyncTableMapping('map', 'orders', 'orders'),
|
||||
targetMode: 'existing_only',
|
||||
keyColumns: ['id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(canUseDataSyncRowErrorIsolation(safe)).toBe(true);
|
||||
expect(
|
||||
canUseDataSyncRowErrorIsolation({
|
||||
...safe,
|
||||
delivery: { ...safe.delivery, autoAddColumns: true },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateDataSyncTask({
|
||||
...safe,
|
||||
target: { ...safe.target, type: 'clickhouse' },
|
||||
delivery: { ...safe.delivery, errorPolicy: 'quarantine' },
|
||||
}).map((item) => item.code),
|
||||
).toContain('row_error_isolation_unsupported');
|
||||
});
|
||||
|
||||
it('keeps query sinks to one target mapping and blocks watermark append', () => {
|
||||
const query = createDataSyncTaskDraft({ id: 'query', kind: 'querySink' });
|
||||
expect(
|
||||
validateDataSyncTask({
|
||||
...query,
|
||||
delivery: { ...query.delivery, writeMode: 'none' },
|
||||
}).map((item) => item.code),
|
||||
).toContain('write_mode_required');
|
||||
expect(
|
||||
validateDataSyncTask({
|
||||
...query,
|
||||
mappings: [
|
||||
...query.mappings,
|
||||
createDataSyncTableMapping('query-map-2', '', 'archive'),
|
||||
],
|
||||
}).map((item) => item.code),
|
||||
).toContain('query_sink_single_mapping_required');
|
||||
|
||||
const watermark = {
|
||||
...createDataSyncTaskDraft({ id: 'watermark', kind: 'reconcile' }),
|
||||
incremental: {
|
||||
mode: 'watermark' as const,
|
||||
column: 'updated_at',
|
||||
tieBreaker: 'id',
|
||||
overlapWindowMs: 0,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
validateDataSyncTask({
|
||||
...watermark,
|
||||
delivery: { ...watermark.delivery, writeMode: 'append', retryLimit: 0 },
|
||||
}).map((item) => item.code),
|
||||
).toContain('watermark_append_unsupported');
|
||||
});
|
||||
});
|
||||
863
frontend/src/components/data-sync/model.ts
Normal file
863
frontend/src/components/data-sync/model.ts
Normal file
@@ -0,0 +1,863 @@
|
||||
export const DATA_SYNC_TASK_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type DataSyncTaskKind =
|
||||
| 'migration'
|
||||
| 'reconcile'
|
||||
| 'querySink'
|
||||
| 'compare'
|
||||
| 'cdc';
|
||||
|
||||
export type DataSyncTaskLifecycle =
|
||||
| 'draft'
|
||||
| 'ready'
|
||||
| 'enabled'
|
||||
| 'paused'
|
||||
| 'archived';
|
||||
|
||||
export type DataSyncTaskStage =
|
||||
| 'endpoints'
|
||||
| 'mappings'
|
||||
| 'delivery'
|
||||
| 'trigger'
|
||||
| 'preflight';
|
||||
|
||||
export type DataSyncCompareMode = 'schema' | 'data' | 'both';
|
||||
|
||||
export type DataSyncEndpointRef = {
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
type: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Credential-free connection data that is safe to expose to the workbench.
|
||||
* Concrete gateways must never add usernames, passwords, DSNs, or SSH secrets.
|
||||
*/
|
||||
export type DataSyncSavedConnectionView = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
readable: boolean;
|
||||
writable: boolean;
|
||||
};
|
||||
|
||||
export type DataSyncDatabaseMetadata = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DataSyncObjectMetadata = {
|
||||
name: string;
|
||||
kind: 'table' | 'view' | 'collection';
|
||||
rowCount?: number;
|
||||
dataBytes?: number;
|
||||
indexBytes?: number;
|
||||
};
|
||||
|
||||
export type DataSyncFieldMetadata = {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
ordinal: number;
|
||||
key: boolean;
|
||||
};
|
||||
|
||||
export type DataSyncFieldMapping = {
|
||||
id: string;
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
transform: string;
|
||||
transformArgument?: string;
|
||||
nullable: boolean;
|
||||
};
|
||||
|
||||
export type DataSyncTableMapping = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
sourceObject: string;
|
||||
targetObject: string;
|
||||
targetMode: 'create_or_reuse' | 'existing_only';
|
||||
keyColumns: string[];
|
||||
watermark?: {
|
||||
column: string;
|
||||
tieBreaker: string;
|
||||
};
|
||||
fields: DataSyncFieldMapping[];
|
||||
};
|
||||
|
||||
export type DataSyncDeliveryPolicy = {
|
||||
writeMode: 'none' | 'append' | 'upsert' | 'overwrite';
|
||||
errorPolicy: 'stop' | 'skip' | 'quarantine';
|
||||
batchSize: number;
|
||||
commitEvery: number;
|
||||
retryLimit: number;
|
||||
retryBackoffMs: number;
|
||||
propagateDeletes: boolean;
|
||||
autoAddColumns: boolean;
|
||||
createIndexes: boolean;
|
||||
captureErrorPayload: boolean;
|
||||
};
|
||||
|
||||
export type DataSyncTriggerPolicy =
|
||||
| { mode: 'manual' }
|
||||
| { mode: 'once'; runAt: string; timezone: string }
|
||||
| { mode: 'interval'; intervalSeconds: number; timezone: string }
|
||||
| {
|
||||
mode: 'cron';
|
||||
expression: string;
|
||||
timezone: string;
|
||||
overlap: 'skip' | 'queue';
|
||||
}
|
||||
| { mode: 'continuous' };
|
||||
|
||||
export type DataSyncIncrementalPolicy =
|
||||
| { mode: 'snapshot' }
|
||||
| {
|
||||
mode: 'watermark';
|
||||
column: string;
|
||||
tieBreaker: string;
|
||||
overlapWindowMs: number;
|
||||
}
|
||||
| {
|
||||
mode: 'cdc';
|
||||
initialSnapshot: boolean;
|
||||
startPosition: 'latest' | 'earliest' | 'checkpoint';
|
||||
adapter: string;
|
||||
slotName: string;
|
||||
publicationName: string;
|
||||
};
|
||||
|
||||
export type DataSyncTaskDefinition = {
|
||||
schemaVersion: typeof DATA_SYNC_TASK_SCHEMA_VERSION;
|
||||
id: string;
|
||||
revision: number;
|
||||
name: string;
|
||||
kind: DataSyncTaskKind;
|
||||
lifecycle: DataSyncTaskLifecycle;
|
||||
compareMode?: DataSyncCompareMode;
|
||||
sourceMode: 'tables' | 'query';
|
||||
sourceQuery: string;
|
||||
source: DataSyncEndpointRef;
|
||||
target: DataSyncEndpointRef;
|
||||
mappings: DataSyncTableMapping[];
|
||||
delivery: DataSyncDeliveryPolicy;
|
||||
trigger: DataSyncTriggerPolicy;
|
||||
incremental: DataSyncIncrementalPolicy;
|
||||
concurrencyPolicy: 'forbid' | 'queue';
|
||||
resumePolicy: 'never' | 'manual' | 'auto';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DataSyncValidationSeverity = 'blocker' | 'warning' | 'info';
|
||||
|
||||
export type DataSyncValidationCode =
|
||||
| 'definition_invalid'
|
||||
| 'definition_hash_failed'
|
||||
| 'task_name_required'
|
||||
| 'source_connection_required'
|
||||
| 'target_connection_required'
|
||||
| 'source_connection_failed'
|
||||
| 'target_connection_failed'
|
||||
| 'source_connect_failed'
|
||||
| 'source_ping_failed'
|
||||
| 'target_connect_failed'
|
||||
| 'target_ping_failed'
|
||||
| 'same_endpoint'
|
||||
| 'same_object'
|
||||
| 'route_unsupported'
|
||||
| 'source_query_required'
|
||||
| 'source_query_not_read_only'
|
||||
| 'mapping_required'
|
||||
| 'source_object_required'
|
||||
| 'target_object_required'
|
||||
| 'duplicate_source_object'
|
||||
| 'duplicate_target_object'
|
||||
| 'mapping_compile_failed'
|
||||
| 'source_columns_failed'
|
||||
| 'target_columns_failed'
|
||||
| 'source_column_missing'
|
||||
| 'target_column_missing'
|
||||
| 'key_column_missing'
|
||||
| 'watermark_column_missing'
|
||||
| 'target_table_check_failed'
|
||||
| 'target_table_missing'
|
||||
| 'target_table_will_be_created'
|
||||
| 'key_columns_required'
|
||||
| 'batch_size_invalid'
|
||||
| 'commit_every_invalid'
|
||||
| 'write_mode_required'
|
||||
| 'target_protection_blocked'
|
||||
| 'row_error_isolation_unsupported'
|
||||
| 'append_retry_unsupported'
|
||||
| 'append_retry_unsafe'
|
||||
| 'append_resume_unsafe'
|
||||
| 'full_overwrite_non_atomic'
|
||||
| 'watermark_append_unsupported'
|
||||
| 'watermark_tie_breaker_required'
|
||||
| 'watermark_initial_value_unsupported'
|
||||
| 'watermark_runtime_unsupported'
|
||||
| 'watermark_overwrite_unsupported'
|
||||
| 'watermark_delete_unsupported'
|
||||
| 'query_sink_single_mapping_required'
|
||||
| 'query_key_required'
|
||||
| 'query_target_pk_mismatch'
|
||||
| 'query_schema_runtime_validation'
|
||||
| 'watermark_column_required'
|
||||
| 'cron_expression_required'
|
||||
| 'timezone_required'
|
||||
| 'interval_invalid'
|
||||
| 'cdc_incremental_required'
|
||||
| 'cdc_trigger_required'
|
||||
| 'cdc_adapter_required'
|
||||
| 'cdc_initial_snapshot_unsupported'
|
||||
| 'cdc_initial_snapshot_handoff_unsupported'
|
||||
| 'cdc_earliest_unsupported'
|
||||
| 'cdc_probe_failed'
|
||||
| 'cdc_adapter_not_ready'
|
||||
| 'cdc_existing_target_required'
|
||||
| 'cdc_upsert_required'
|
||||
| 'cdc_target_non_atomic'
|
||||
| 'cdc_authoritative_columns_required'
|
||||
| 'cdc_checkpoint_unavailable'
|
||||
| 'cdc_checkpoint_required'
|
||||
| 'cdc_checkpoint_incompatible'
|
||||
| 'compare_route_unsupported'
|
||||
| 'capability_unverified';
|
||||
|
||||
export type DataSyncValidationIssue = {
|
||||
id: string;
|
||||
severity: DataSyncValidationSeverity;
|
||||
code: DataSyncValidationCode;
|
||||
stage: DataSyncTaskStage;
|
||||
mappingId?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type DataSyncPreflightStatus =
|
||||
| 'stale'
|
||||
| 'running'
|
||||
| 'blocked'
|
||||
| 'warning'
|
||||
| 'passed';
|
||||
|
||||
export type DataSyncPreflightSnapshot = {
|
||||
taskId: string;
|
||||
taskRevision: number;
|
||||
status: Exclude<DataSyncPreflightStatus, 'stale' | 'running'>;
|
||||
issues: DataSyncValidationIssue[];
|
||||
/** Backend-owned hash of the exact definition that was checked. */
|
||||
definitionHash: string;
|
||||
/** Production writes remain disabled until an explicit approval grants a token. */
|
||||
approvalRequired: boolean;
|
||||
/** True only when the exact enriched definition already carries a valid approval. */
|
||||
approvalSatisfied: boolean;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type DataSyncApprovalChallenge = {
|
||||
definitionHash: string;
|
||||
notBefore: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type DataSyncApprovalGrant = {
|
||||
definitionHash: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type DataSyncRouteCapability = {
|
||||
level: 'full' | 'partial' | 'unsupported' | 'unknown';
|
||||
canExecute: boolean;
|
||||
supportsAutoCreate: boolean;
|
||||
supportsAutoAddColumns?: boolean;
|
||||
requiresExistingTarget?: boolean;
|
||||
supportsCdc: boolean;
|
||||
};
|
||||
|
||||
export type DataSyncRunStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'cancelling'
|
||||
| 'preflighting'
|
||||
| 'snapshotting'
|
||||
| 'catching_up'
|
||||
| 'streaming'
|
||||
| 'paused'
|
||||
| 'succeeded'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'canceled'
|
||||
| 'cancelled'
|
||||
| 'interrupted';
|
||||
|
||||
export type DataSyncRunRecord = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
status: DataSyncRunStatus;
|
||||
trigger: 'manual' | 'schedule' | 'resume' | 'retry' | 'once' | 'cron' | 'continuous';
|
||||
attempt: number;
|
||||
resumable: boolean;
|
||||
message: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
rowsRead: number;
|
||||
rowsWritten: number;
|
||||
rowsFailed: number;
|
||||
throughput: number;
|
||||
checkpoint: string;
|
||||
};
|
||||
|
||||
export type DataSyncErrorRow = {
|
||||
id: string;
|
||||
runId: string;
|
||||
taskId: string;
|
||||
mappingId: string;
|
||||
sourceObject: string;
|
||||
reason: string;
|
||||
payloadPreview: string;
|
||||
retryable: boolean;
|
||||
status: 'pending' | 'resolved' | 'discarded' | 'unknown';
|
||||
operation: string;
|
||||
};
|
||||
|
||||
export type DataSyncScheduleSummary = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
enabled: boolean;
|
||||
expression: string;
|
||||
timezone: string;
|
||||
nextRunAt: string;
|
||||
};
|
||||
|
||||
export type DataSyncCdcSourceStatus = {
|
||||
taskId: string;
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
type: string;
|
||||
adapter: string;
|
||||
status: 'ready' | 'probing' | 'lagging' | 'offline' | 'unsupported' | 'unknown';
|
||||
lagMs: number | null;
|
||||
checkpoint: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type DataSyncCheckpointSummary = {
|
||||
taskId: string;
|
||||
runId: string;
|
||||
kind: string;
|
||||
phase: string;
|
||||
cursorPreview: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CreateDataSyncTaskInput = {
|
||||
id: string;
|
||||
kind: DataSyncTaskKind;
|
||||
name?: string;
|
||||
now?: string;
|
||||
compareMode?: DataSyncCompareMode;
|
||||
sourceConnectionId?: string;
|
||||
};
|
||||
|
||||
const emptyEndpoint = (connectionId = ''): DataSyncEndpointRef => ({
|
||||
connectionId,
|
||||
connectionName: '',
|
||||
type: '',
|
||||
database: '',
|
||||
schema: '',
|
||||
});
|
||||
|
||||
export const createDataSyncTableMapping = (
|
||||
id: string,
|
||||
sourceObject = '',
|
||||
targetObject = '',
|
||||
): DataSyncTableMapping => ({
|
||||
id,
|
||||
enabled: true,
|
||||
sourceObject,
|
||||
targetObject,
|
||||
targetMode: 'existing_only',
|
||||
keyColumns: [],
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const normalizeMetadataName = (value: string): string =>
|
||||
value.trim().toLowerCase();
|
||||
|
||||
const metadataObjectBaseName = (value: string): string => {
|
||||
const parts = value.trim().split('.');
|
||||
return (parts[parts.length - 1] || '')
|
||||
.trim()
|
||||
.replace(/^[`"\[]/, '')
|
||||
.replace(/[`"\]]$/, '');
|
||||
};
|
||||
|
||||
const pristineDataSyncMapping = (mapping: DataSyncTableMapping): boolean =>
|
||||
!mapping.sourceObject.trim() &&
|
||||
!mapping.targetObject.trim() &&
|
||||
mapping.keyColumns.length === 0 &&
|
||||
mapping.fields.length === 0;
|
||||
|
||||
type BuildDataSyncMappingsInput = {
|
||||
taskId: string;
|
||||
taskKind: DataSyncTaskKind;
|
||||
sourceNames: string[];
|
||||
targetObjects: DataSyncObjectMetadata[];
|
||||
existingMappings: DataSyncTableMapping[];
|
||||
keyColumnsBySource?: Record<string, string[]>;
|
||||
allowTargetCreate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a user's object selection into safe, editable mappings. Existing work is
|
||||
* retained, duplicate sources are ignored, and only one unconfigured starter row
|
||||
* is replaced. Missing targets are auto-created only for one-time migrations.
|
||||
*/
|
||||
export const buildDataSyncMappingsFromSelection = ({
|
||||
taskId,
|
||||
taskKind,
|
||||
sourceNames,
|
||||
targetObjects,
|
||||
existingMappings,
|
||||
keyColumnsBySource = {},
|
||||
allowTargetCreate,
|
||||
}: BuildDataSyncMappingsInput): DataSyncTableMapping[] => {
|
||||
const retained = existingMappings.filter((mapping) => !pristineDataSyncMapping(mapping));
|
||||
const seenSources = new Set(
|
||||
retained
|
||||
.map((mapping) => normalizeMetadataName(mapping.sourceObject))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const usedMappingIds = new Set(retained.map((mapping) => mapping.id));
|
||||
const targetCandidates = targetObjects.filter((object) => object.kind !== 'view');
|
||||
|
||||
const matchTarget = (sourceName: string): string => {
|
||||
const sourceFull = normalizeMetadataName(sourceName);
|
||||
const sourceBase = normalizeMetadataName(metadataObjectBaseName(sourceName));
|
||||
const exact = targetCandidates.find(
|
||||
(candidate) => normalizeMetadataName(candidate.name) === sourceFull,
|
||||
);
|
||||
if (exact) return exact.name;
|
||||
const baseMatches = targetCandidates.filter(
|
||||
(candidate) =>
|
||||
normalizeMetadataName(metadataObjectBaseName(candidate.name)) === sourceBase,
|
||||
);
|
||||
return baseMatches.length === 1 ? baseMatches[0].name : '';
|
||||
};
|
||||
|
||||
const additions = sourceNames.flatMap((rawName, selectionIndex) => {
|
||||
const sourceObject = rawName.trim();
|
||||
const sourceKey = normalizeMetadataName(sourceObject);
|
||||
if (!sourceKey || seenSources.has(sourceKey)) return [];
|
||||
seenSources.add(sourceKey);
|
||||
|
||||
const matchedTarget = matchTarget(sourceObject);
|
||||
const canCreateTarget =
|
||||
(allowTargetCreate ?? taskKind === 'migration') && !matchedTarget;
|
||||
const idStem = sourceKey.replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '');
|
||||
let mappingId = `${taskId}:mapping:${idStem || selectionIndex + 1}`;
|
||||
let suffix = 2;
|
||||
while (usedMappingIds.has(mappingId)) {
|
||||
mappingId = `${taskId}:mapping:${idStem || selectionIndex + 1}:${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedMappingIds.add(mappingId);
|
||||
const mapping = createDataSyncTableMapping(
|
||||
mappingId,
|
||||
sourceObject,
|
||||
matchedTarget || (canCreateTarget ? metadataObjectBaseName(sourceObject) : ''),
|
||||
);
|
||||
mapping.targetMode = canCreateTarget ? 'create_or_reuse' : 'existing_only';
|
||||
mapping.keyColumns = [
|
||||
...(keyColumnsBySource[sourceKey] ||
|
||||
keyColumnsBySource[normalizeMetadataName(metadataObjectBaseName(sourceObject))] ||
|
||||
[]),
|
||||
];
|
||||
return [mapping];
|
||||
});
|
||||
|
||||
return [...retained, ...additions];
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces a deterministic, editable mapping for fields that share a name.
|
||||
* Existing transforms are retained so refreshing metadata never discards work.
|
||||
*/
|
||||
export const autoMatchDataSyncFields = (
|
||||
mappingId: string,
|
||||
sourceFields: DataSyncFieldMetadata[],
|
||||
targetFields: DataSyncFieldMetadata[],
|
||||
existing: DataSyncFieldMapping[] = [],
|
||||
): DataSyncFieldMapping[] => {
|
||||
const targetByName = new Map(
|
||||
targetFields.map((field) => [normalizeMetadataName(field.name), field] as const),
|
||||
);
|
||||
const existingByPair = new Map<string, DataSyncFieldMapping>(
|
||||
existing.map((field) => [
|
||||
`${normalizeMetadataName(field.sourceField)}\u0000${normalizeMetadataName(
|
||||
field.targetField,
|
||||
)}`,
|
||||
field,
|
||||
] as const),
|
||||
);
|
||||
|
||||
return sourceFields.flatMap((sourceField, index) => {
|
||||
const targetField = targetByName.get(normalizeMetadataName(sourceField.name));
|
||||
if (!targetField) return [];
|
||||
const pairKey = `${normalizeMetadataName(sourceField.name)}\u0000${normalizeMetadataName(
|
||||
targetField.name,
|
||||
)}`;
|
||||
const previous = existingByPair.get(pairKey);
|
||||
return [
|
||||
{
|
||||
id: previous?.id || `${mappingId}:field:${index + 1}`,
|
||||
sourceField: sourceField.name,
|
||||
targetField: targetField.name,
|
||||
sourceType: sourceField.type,
|
||||
targetType: targetField.type,
|
||||
transform: previous?.transform || '',
|
||||
transformArgument: previous?.transformArgument || '',
|
||||
nullable: targetField.nullable,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const defaultWriteMode = (
|
||||
kind: DataSyncTaskKind,
|
||||
): DataSyncDeliveryPolicy['writeMode'] => {
|
||||
if (kind === 'compare') return 'none';
|
||||
if (kind === 'querySink') return 'append';
|
||||
return 'upsert';
|
||||
};
|
||||
|
||||
export const createDataSyncTaskDraft = ({
|
||||
id,
|
||||
kind,
|
||||
name = '',
|
||||
now = new Date().toISOString(),
|
||||
compareMode,
|
||||
sourceConnectionId = '',
|
||||
}: CreateDataSyncTaskInput): DataSyncTaskDefinition => ({
|
||||
schemaVersion: DATA_SYNC_TASK_SCHEMA_VERSION,
|
||||
id,
|
||||
revision: 1,
|
||||
name,
|
||||
kind,
|
||||
lifecycle: 'draft',
|
||||
compareMode: kind === 'compare' ? compareMode || 'data' : undefined,
|
||||
sourceMode: kind === 'querySink' ? 'query' : 'tables',
|
||||
sourceQuery: '',
|
||||
source: emptyEndpoint(sourceConnectionId),
|
||||
target: emptyEndpoint(),
|
||||
mappings:
|
||||
kind === 'querySink'
|
||||
? [createDataSyncTableMapping(`${id}:mapping:1`)]
|
||||
: [],
|
||||
delivery: {
|
||||
writeMode: defaultWriteMode(kind),
|
||||
errorPolicy: 'stop',
|
||||
batchSize: 1_000,
|
||||
commitEvery: 1_000,
|
||||
retryLimit: kind === 'querySink' ? 0 : 3,
|
||||
retryBackoffMs: 500,
|
||||
propagateDeletes: false,
|
||||
autoAddColumns: false,
|
||||
createIndexes: false,
|
||||
captureErrorPayload: false,
|
||||
},
|
||||
trigger: kind === 'cdc' ? { mode: 'continuous' } : { mode: 'manual' },
|
||||
incremental:
|
||||
kind === 'cdc'
|
||||
? {
|
||||
mode: 'cdc',
|
||||
initialSnapshot: false,
|
||||
startPosition: 'latest',
|
||||
adapter: '',
|
||||
slotName: '',
|
||||
publicationName: '',
|
||||
}
|
||||
: { mode: 'snapshot' },
|
||||
concurrencyPolicy: 'forbid',
|
||||
resumePolicy: 'manual',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
export const reviseDataSyncTask = (
|
||||
task: DataSyncTaskDefinition,
|
||||
patch: Partial<Omit<DataSyncTaskDefinition, 'id' | 'schemaVersion' | 'revision' | 'createdAt'>>,
|
||||
now = new Date().toISOString(),
|
||||
): DataSyncTaskDefinition => ({
|
||||
...task,
|
||||
...patch,
|
||||
revision: task.revision + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const normalize = (value: unknown): string => String(value ?? '').trim();
|
||||
|
||||
const normalizeAtomicTargetType = (value: string): string => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'postgresql') return 'postgres';
|
||||
if (['mssql', 'sql_server', 'sql-server'].includes(normalized)) return 'sqlserver';
|
||||
if (['kingbase8', 'kingbasees', 'kingbasev8'].includes(normalized)) return 'kingbase';
|
||||
if (['open_gauss', 'open-gauss'].includes(normalized)) return 'opengauss';
|
||||
if (['gauss_db', 'gauss-db'].includes(normalized)) return 'gaussdb';
|
||||
if (['intersystems', 'intersystemsiris', 'inter-systems', 'inter-systems-iris'].includes(normalized)) return 'iris';
|
||||
if (['dm', 'dm8'].includes(normalized)) return 'dameng';
|
||||
if (normalized === 'sqlite3') return 'sqlite';
|
||||
if (['goldendb', 'greatdb', 'gdb'].includes(normalized)) return 'mysql';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const ATOMIC_ROW_ISOLATION_TARGETS = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'oceanbase',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'opengauss',
|
||||
'gaussdb',
|
||||
'oracle',
|
||||
'sqlserver',
|
||||
'dameng',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'iris',
|
||||
]);
|
||||
|
||||
/** Mirrors the backend's fail-closed snapshot/query row-isolation contract. */
|
||||
export const canUseDataSyncRowErrorIsolation = (
|
||||
task: DataSyncTaskDefinition,
|
||||
): boolean => {
|
||||
if (task.kind === 'cdc') return true;
|
||||
if (
|
||||
(task.kind !== 'reconcile' && task.kind !== 'querySink') ||
|
||||
task.incremental.mode !== 'snapshot' ||
|
||||
task.delivery.writeMode === 'overwrite' ||
|
||||
task.delivery.autoAddColumns ||
|
||||
task.delivery.createIndexes ||
|
||||
task.delivery.propagateDeletes ||
|
||||
!ATOMIC_ROW_ISOLATION_TARGETS.has(normalizeAtomicTargetType(task.target.type))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const enabledMappings = task.mappings.filter((mapping) => mapping.enabled);
|
||||
return (
|
||||
enabledMappings.length > 0 &&
|
||||
enabledMappings.every((mapping) => mapping.targetMode === 'existing_only')
|
||||
);
|
||||
};
|
||||
|
||||
const issue = (
|
||||
code: DataSyncValidationCode,
|
||||
severity: DataSyncValidationSeverity,
|
||||
stage: DataSyncTaskStage,
|
||||
mappingId?: string,
|
||||
): DataSyncValidationIssue => ({
|
||||
id: mappingId ? `${code}:${mappingId}` : code,
|
||||
code,
|
||||
severity,
|
||||
stage,
|
||||
...(mappingId ? { mappingId } : {}),
|
||||
});
|
||||
|
||||
export const validateDataSyncTask = (
|
||||
task: DataSyncTaskDefinition,
|
||||
): DataSyncValidationIssue[] => {
|
||||
const issues: DataSyncValidationIssue[] = [];
|
||||
if (!normalize(task.name)) {
|
||||
issues.push(issue('task_name_required', 'blocker', 'endpoints'));
|
||||
}
|
||||
if (!normalize(task.source.connectionId)) {
|
||||
issues.push(issue('source_connection_required', 'blocker', 'endpoints'));
|
||||
}
|
||||
if (!normalize(task.target.connectionId)) {
|
||||
issues.push(issue('target_connection_required', 'blocker', 'endpoints'));
|
||||
}
|
||||
if (
|
||||
normalize(task.source.connectionId) &&
|
||||
normalize(task.source.connectionId) === normalize(task.target.connectionId) &&
|
||||
normalize(task.source.database).toLowerCase() ===
|
||||
normalize(task.target.database).toLowerCase()
|
||||
) {
|
||||
issues.push(issue('same_endpoint', 'warning', 'endpoints'));
|
||||
}
|
||||
if (task.sourceMode === 'query' && !normalize(task.sourceQuery)) {
|
||||
issues.push(issue('source_query_required', 'blocker', 'endpoints'));
|
||||
}
|
||||
|
||||
const enabledMappings = task.mappings.filter((mapping) => mapping.enabled);
|
||||
if (enabledMappings.length === 0) {
|
||||
issues.push(issue('mapping_required', 'blocker', 'mappings'));
|
||||
}
|
||||
if (task.kind === 'querySink' && task.mappings.length !== 1) {
|
||||
issues.push(
|
||||
issue('query_sink_single_mapping_required', 'blocker', 'mappings'),
|
||||
);
|
||||
}
|
||||
const sourceKeys = new Set<string>();
|
||||
const targetKeys = new Set<string>();
|
||||
enabledMappings.forEach((mapping) => {
|
||||
if (task.kind !== 'querySink') {
|
||||
const sourceObject = normalize(mapping.sourceObject);
|
||||
if (!sourceObject) {
|
||||
issues.push(
|
||||
issue('source_object_required', 'blocker', 'mappings', mapping.id),
|
||||
);
|
||||
} else {
|
||||
const sourceKey = sourceObject.toLowerCase();
|
||||
if (sourceKeys.has(sourceKey)) {
|
||||
issues.push(
|
||||
issue('duplicate_source_object', 'blocker', 'mappings', mapping.id),
|
||||
);
|
||||
}
|
||||
sourceKeys.add(sourceKey);
|
||||
}
|
||||
}
|
||||
const targetObject = normalize(mapping.targetObject);
|
||||
if (!targetObject) {
|
||||
issues.push(
|
||||
issue('target_object_required', 'blocker', 'mappings', mapping.id),
|
||||
);
|
||||
} else {
|
||||
const targetKey = targetObject.toLowerCase();
|
||||
if (targetKeys.has(targetKey)) {
|
||||
issues.push(
|
||||
issue('duplicate_target_object', 'blocker', 'mappings', mapping.id),
|
||||
);
|
||||
}
|
||||
targetKeys.add(targetKey);
|
||||
}
|
||||
if (
|
||||
(task.kind === 'reconcile' || task.kind === 'cdc') &&
|
||||
mapping.keyColumns.map(normalize).filter(Boolean).length === 0
|
||||
) {
|
||||
issues.push(
|
||||
issue('key_columns_required', 'blocker', 'mappings', mapping.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!Number.isInteger(task.delivery.batchSize) ||
|
||||
task.delivery.batchSize < 1 ||
|
||||
task.delivery.batchSize > 10_000
|
||||
) {
|
||||
issues.push(issue('batch_size_invalid', 'blocker', 'delivery'));
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(task.delivery.commitEvery) ||
|
||||
task.delivery.commitEvery < task.delivery.batchSize
|
||||
) {
|
||||
issues.push(issue('commit_every_invalid', 'blocker', 'delivery'));
|
||||
}
|
||||
if (task.kind !== 'compare' && task.delivery.writeMode === 'none') {
|
||||
issues.push(issue('write_mode_required', 'blocker', 'delivery'));
|
||||
}
|
||||
if (
|
||||
task.delivery.errorPolicy !== 'stop' &&
|
||||
!canUseDataSyncRowErrorIsolation(task)
|
||||
) {
|
||||
issues.push(
|
||||
issue('row_error_isolation_unsupported', 'blocker', 'delivery'),
|
||||
);
|
||||
}
|
||||
if (task.delivery.writeMode === 'append' && task.delivery.retryLimit !== 0) {
|
||||
issues.push(issue('append_retry_unsupported', 'blocker', 'delivery'));
|
||||
}
|
||||
if (
|
||||
task.incremental.mode === 'watermark' &&
|
||||
task.delivery.writeMode === 'append'
|
||||
) {
|
||||
issues.push(issue('watermark_append_unsupported', 'blocker', 'delivery'));
|
||||
}
|
||||
|
||||
if (
|
||||
task.incremental.mode === 'watermark' &&
|
||||
!normalize(task.incremental.column)
|
||||
) {
|
||||
issues.push(issue('watermark_column_required', 'blocker', 'trigger'));
|
||||
}
|
||||
if (task.trigger.mode === 'cron') {
|
||||
if (!normalize(task.trigger.expression)) {
|
||||
issues.push(issue('cron_expression_required', 'blocker', 'trigger'));
|
||||
}
|
||||
if (!normalize(task.trigger.timezone)) {
|
||||
issues.push(issue('timezone_required', 'blocker', 'trigger'));
|
||||
}
|
||||
}
|
||||
if (
|
||||
task.trigger.mode === 'interval' &&
|
||||
(!Number.isInteger(task.trigger.intervalSeconds) || task.trigger.intervalSeconds < 60)
|
||||
) {
|
||||
issues.push(issue('interval_invalid', 'blocker', 'trigger'));
|
||||
}
|
||||
if (task.kind === 'cdc') {
|
||||
if (task.incremental.mode !== 'cdc') {
|
||||
issues.push(issue('cdc_incremental_required', 'blocker', 'trigger'));
|
||||
}
|
||||
if (task.trigger.mode !== 'continuous') {
|
||||
issues.push(issue('cdc_trigger_required', 'blocker', 'trigger'));
|
||||
}
|
||||
if (task.incremental.mode === 'cdc') {
|
||||
if (!normalize(task.incremental.adapter)) {
|
||||
issues.push(issue('cdc_adapter_required', 'blocker', 'trigger'));
|
||||
}
|
||||
if (task.incremental.initialSnapshot) {
|
||||
issues.push(
|
||||
issue('cdc_initial_snapshot_unsupported', 'blocker', 'trigger'),
|
||||
);
|
||||
}
|
||||
if (task.incremental.startPosition === 'earliest') {
|
||||
issues.push(issue('cdc_earliest_unsupported', 'blocker', 'trigger'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
export const resolveDataSyncPreflightStatus = (
|
||||
issues: DataSyncValidationIssue[],
|
||||
): DataSyncPreflightSnapshot['status'] => {
|
||||
if (issues.some((item) => item.severity === 'blocker')) return 'blocked';
|
||||
if (issues.some((item) => item.severity === 'warning')) return 'warning';
|
||||
return 'passed';
|
||||
};
|
||||
|
||||
export const isDataSyncPreflightCurrent = (
|
||||
task: DataSyncTaskDefinition,
|
||||
preflight: DataSyncPreflightSnapshot | null,
|
||||
): boolean =>
|
||||
Boolean(
|
||||
preflight &&
|
||||
preflight.taskId === task.id &&
|
||||
preflight.taskRevision === task.revision,
|
||||
);
|
||||
|
||||
export const canStartDataSyncTask = (
|
||||
task: DataSyncTaskDefinition,
|
||||
preflight: DataSyncPreflightSnapshot | null,
|
||||
approval: DataSyncApprovalGrant | null = null,
|
||||
now = Date.now(),
|
||||
): boolean =>
|
||||
(task.lifecycle === 'ready' || task.lifecycle === 'enabled') &&
|
||||
isDataSyncPreflightCurrent(task, preflight) &&
|
||||
(preflight?.approvalRequired === false ||
|
||||
preflight?.approvalSatisfied === true ||
|
||||
Boolean(
|
||||
approval &&
|
||||
approval.definitionHash === preflight?.definitionHash &&
|
||||
Date.parse(approval.expiresAt) > now,
|
||||
)) &&
|
||||
(preflight?.status === 'passed' || preflight?.status === 'warning');
|
||||
368
frontend/src/components/data-sync/wailsDto.test.ts
Normal file
368
frontend/src/components/data-sync/wailsDto.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createDataSyncTableMapping,
|
||||
createDataSyncTaskDraft,
|
||||
reviseDataSyncTask,
|
||||
} from './model';
|
||||
import {
|
||||
DataSyncGatewayProtocolError,
|
||||
decodeDataSyncJobDefinition,
|
||||
decodeObjectMetadata,
|
||||
decodeDataSyncPreflightQuery,
|
||||
decodeRouteCapability,
|
||||
encodeDataSyncJobDefinition,
|
||||
requireWailsQueryData,
|
||||
} from './wailsDto';
|
||||
|
||||
const configuredTask = (
|
||||
kind: 'migration' | 'reconcile' | 'querySink' | 'compare' | 'cdc' = 'reconcile',
|
||||
) => {
|
||||
const draft = createDataSyncTaskDraft({
|
||||
id: `persisted-${kind}`,
|
||||
kind,
|
||||
name: `${kind} task`,
|
||||
now: '2026-08-08T00:00:00.000Z',
|
||||
});
|
||||
return reviseDataSyncTask(
|
||||
draft,
|
||||
{
|
||||
lifecycle: 'ready',
|
||||
source: {
|
||||
connectionId: 'source-id',
|
||||
connectionName: 'Source',
|
||||
type: 'mysql',
|
||||
database: 'sales',
|
||||
schema: 'public',
|
||||
},
|
||||
target: {
|
||||
connectionId: 'target-id',
|
||||
connectionName: 'Target',
|
||||
type: 'postgresql',
|
||||
database: 'warehouse',
|
||||
schema: 'ods',
|
||||
},
|
||||
mappings: [
|
||||
{
|
||||
...createDataSyncTableMapping('map-1', 'public.orders', 'ods.orders'),
|
||||
targetMode: 'create_or_reuse',
|
||||
keyColumns: ['id'],
|
||||
fields: [
|
||||
{
|
||||
id: 'field-1',
|
||||
sourceField: 'name',
|
||||
targetField: 'name',
|
||||
sourceType: 'varchar',
|
||||
targetType: 'text',
|
||||
transform: 'trim',
|
||||
transformArgument: '{"unicode":true}',
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'2026-08-08T00:01:00.000Z',
|
||||
);
|
||||
};
|
||||
|
||||
describe('data sync Wails DTO boundary', () => {
|
||||
it('decodes optional object size metadata without inventing missing values', () => {
|
||||
expect(
|
||||
decodeObjectMetadata(
|
||||
[
|
||||
{
|
||||
Table: 'orders',
|
||||
Rows: '1250',
|
||||
Data_length: '65536',
|
||||
Index_length: '16384',
|
||||
},
|
||||
{ Table: 'empty_table', Rows: '0' },
|
||||
{ Table: 'unknown_size' },
|
||||
],
|
||||
'mysql',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
name: 'orders',
|
||||
kind: 'table',
|
||||
rowCount: 1250,
|
||||
dataBytes: 65536,
|
||||
indexBytes: 16384,
|
||||
},
|
||||
{ name: 'empty_table', kind: 'table', rowCount: 0 },
|
||||
{ name: 'unknown_size', kind: 'table' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes route target capabilities and defaults absent flags to false', () => {
|
||||
expect(
|
||||
decodeRouteCapability({
|
||||
supportLevel: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
supportsAutoAddColumns: true,
|
||||
requiresExistingTarget: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
level: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
supportsAutoAddColumns: true,
|
||||
requiresExistingTarget: true,
|
||||
});
|
||||
expect(
|
||||
decodeRouteCapability({
|
||||
supportLevel: 'partial',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
supportsAutoAddColumns: false,
|
||||
requiresExistingTarget: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips versioned delivery, mapping, schedule, and target strategy fields', () => {
|
||||
const task = reviseDataSyncTask(configuredTask(), {
|
||||
delivery: {
|
||||
...configuredTask().delivery,
|
||||
writeMode: 'upsert',
|
||||
errorPolicy: 'stop',
|
||||
batchSize: 250,
|
||||
commitEvery: 250,
|
||||
retryLimit: 4,
|
||||
retryBackoffMs: 750,
|
||||
autoAddColumns: true,
|
||||
createIndexes: true,
|
||||
propagateDeletes: true,
|
||||
captureErrorPayload: false,
|
||||
},
|
||||
trigger: {
|
||||
mode: 'cron',
|
||||
expression: '0 */5 * * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
overlap: 'queue',
|
||||
},
|
||||
concurrencyPolicy: 'queue',
|
||||
resumePolicy: 'auto',
|
||||
});
|
||||
const wire = encodeDataSyncJobDefinition(task, {
|
||||
approval: { definitionHash: 'untrusted' },
|
||||
});
|
||||
|
||||
expect(wire).not.toHaveProperty('approval');
|
||||
expect(wire).toMatchObject({
|
||||
kind: 'reconcile',
|
||||
incrementalMode: 'snapshot',
|
||||
concurrencyPolicy: 'queue',
|
||||
resumePolicy: 'auto',
|
||||
options: {
|
||||
syncMode: 'insert_update',
|
||||
targetTableStrategy: 'smart',
|
||||
batchSize: 250,
|
||||
maxRetries: 4,
|
||||
retryBackoffMillis: 750,
|
||||
},
|
||||
schedule: { kind: 'cron', cronExpression: '0 */5 * * * *' },
|
||||
});
|
||||
expect(decodeDataSyncJobDefinition(wire)).toMatchObject({
|
||||
kind: 'reconcile',
|
||||
delivery: {
|
||||
writeMode: 'upsert',
|
||||
batchSize: 250,
|
||||
retryLimit: 4,
|
||||
retryBackoffMs: 750,
|
||||
},
|
||||
trigger: { mode: 'cron', overlap: 'queue' },
|
||||
concurrencyPolicy: 'queue',
|
||||
resumePolicy: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps query sink, compare content, and CDC into backend wire values', () => {
|
||||
const querySink = reviseDataSyncTask(configuredTask('querySink'), {
|
||||
sourceQuery: 'SELECT id FROM orders',
|
||||
});
|
||||
const compare = configuredTask('compare');
|
||||
const cdcBase = configuredTask('cdc');
|
||||
const cdc = reviseDataSyncTask(cdcBase, {
|
||||
incremental: {
|
||||
mode: 'cdc',
|
||||
initialSnapshot: false,
|
||||
startPosition: 'latest',
|
||||
adapter: 'mongodb-change-stream',
|
||||
slotName: '',
|
||||
publicationName: '',
|
||||
},
|
||||
trigger: { mode: 'continuous' },
|
||||
});
|
||||
|
||||
expect(encodeDataSyncJobDefinition(querySink)).toMatchObject({
|
||||
kind: 'query_sink',
|
||||
sourceQuery: 'SELECT id FROM orders',
|
||||
options: { syncMode: 'insert_only', maxRetries: 0 },
|
||||
});
|
||||
expect(encodeDataSyncJobDefinition(compare)).toMatchObject({
|
||||
kind: 'compare',
|
||||
options: { content: 'data' },
|
||||
});
|
||||
expect(encodeDataSyncJobDefinition(cdc)).toMatchObject({
|
||||
kind: 'reconcile',
|
||||
incrementalMode: 'cdc',
|
||||
cdc: {
|
||||
adapter: 'mongodb-change-stream',
|
||||
initialSnapshot: false,
|
||||
startPosition: 'latest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for malformed QueryResult and unsafe policies', () => {
|
||||
expect(() => requireWailsQueryData({ success: true }, 'Example')).toThrow(
|
||||
DataSyncGatewayProtocolError,
|
||||
);
|
||||
expect(() => requireWailsQueryData({ success: false, data: [] }, 'Example')).toThrow(
|
||||
DataSyncGatewayProtocolError,
|
||||
);
|
||||
|
||||
const snapshot = configuredTask('migration');
|
||||
expect(() =>
|
||||
encodeDataSyncJobDefinition(
|
||||
reviseDataSyncTask(snapshot, {
|
||||
delivery: { ...snapshot.delivery, errorPolicy: 'skip' },
|
||||
}),
|
||||
),
|
||||
).toThrow('row isolation requires');
|
||||
|
||||
const querySink = configuredTask('querySink');
|
||||
expect(() =>
|
||||
encodeDataSyncJobDefinition(
|
||||
reviseDataSyncTask(querySink, {
|
||||
delivery: { ...querySink.delivery, writeMode: 'none' },
|
||||
}),
|
||||
),
|
||||
).toThrow('requires an explicit delivery mode');
|
||||
expect(() =>
|
||||
encodeDataSyncJobDefinition(
|
||||
reviseDataSyncTask(querySink, {
|
||||
delivery: { ...querySink.delivery, retryLimit: 1 },
|
||||
}),
|
||||
),
|
||||
).toThrow('requires retryLimit 0');
|
||||
});
|
||||
|
||||
it('encodes safe snapshot row isolation and preserves per-mapping watermarks', () => {
|
||||
const base = configuredTask('reconcile');
|
||||
const safeIsolation = reviseDataSyncTask(base, {
|
||||
mappings: base.mappings.map((mapping) => ({
|
||||
...mapping,
|
||||
targetMode: 'existing_only',
|
||||
})),
|
||||
delivery: {
|
||||
...base.delivery,
|
||||
errorPolicy: 'quarantine',
|
||||
captureErrorPayload: true,
|
||||
},
|
||||
});
|
||||
expect(encodeDataSyncJobDefinition(safeIsolation)).toMatchObject({
|
||||
options: { errorPolicy: 'skip_row', captureErrorPayload: true },
|
||||
});
|
||||
|
||||
const second = {
|
||||
...createDataSyncTableMapping('map-2', 'public.customers', 'ods.customers'),
|
||||
keyColumns: ['customer_id'],
|
||||
watermark: { column: 'modified_at', tieBreaker: 'customer_id' },
|
||||
};
|
||||
const watermark = reviseDataSyncTask(base, {
|
||||
incremental: {
|
||||
mode: 'watermark',
|
||||
column: 'updated_at',
|
||||
tieBreaker: 'id',
|
||||
overlapWindowMs: 0,
|
||||
},
|
||||
mappings: [
|
||||
{
|
||||
...base.mappings[0],
|
||||
watermark: { column: 'updated_at', tieBreaker: 'id' },
|
||||
},
|
||||
second,
|
||||
],
|
||||
});
|
||||
const decoded = decodeDataSyncJobDefinition(
|
||||
encodeDataSyncJobDefinition(watermark),
|
||||
);
|
||||
expect(decoded.mappings.map((mapping) => mapping.watermark)).toEqual([
|
||||
{ column: 'updated_at', tieBreaker: 'id' },
|
||||
{ column: 'modified_at', tieBreaker: 'customer_id' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts only a structured blocked payload when preflight success is false', () => {
|
||||
const task = configuredTask();
|
||||
const definition = encodeDataSyncJobDefinition(task);
|
||||
const blocked = decodeDataSyncPreflightQuery(
|
||||
{
|
||||
success: false,
|
||||
message: 'blocked',
|
||||
data: {
|
||||
status: 'blocked',
|
||||
definition,
|
||||
definitionHash: 'hash',
|
||||
approvalRequired: false,
|
||||
capability: {
|
||||
supportLevel: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
},
|
||||
issues: [
|
||||
{
|
||||
code: 'route_unsupported',
|
||||
severity: 'blocker',
|
||||
stage: 'endpoints',
|
||||
message: 'unsupported route',
|
||||
},
|
||||
],
|
||||
checkedAt: Date.parse('2026-08-08T00:02:00.000Z'),
|
||||
},
|
||||
},
|
||||
task,
|
||||
);
|
||||
|
||||
expect(blocked.snapshot).toMatchObject({
|
||||
status: 'blocked',
|
||||
approvalSatisfied: false,
|
||||
issues: [{ message: 'unsupported route' }],
|
||||
});
|
||||
const earlyBlocked = decodeDataSyncPreflightQuery(
|
||||
{
|
||||
success: false,
|
||||
data: {
|
||||
status: 'blocked',
|
||||
definition,
|
||||
approvalRequired: false,
|
||||
issues: [
|
||||
{
|
||||
code: 'definition_invalid',
|
||||
severity: 'blocker',
|
||||
stage: 'endpoints',
|
||||
message: 'invalid definition',
|
||||
},
|
||||
],
|
||||
checkedAt: Date.parse('2026-08-08T00:02:00.000Z'),
|
||||
},
|
||||
},
|
||||
task,
|
||||
);
|
||||
expect(earlyBlocked.capability).toMatchObject({
|
||||
level: 'unknown',
|
||||
canExecute: false,
|
||||
});
|
||||
expect(() =>
|
||||
decodeDataSyncPreflightQuery(
|
||||
{ success: false, data: { ...blocked, status: 'passed' } },
|
||||
task,
|
||||
),
|
||||
).toThrow(DataSyncGatewayProtocolError);
|
||||
});
|
||||
});
|
||||
1192
frontend/src/components/data-sync/wailsDto.ts
Normal file
1192
frontend/src/components/data-sync/wailsDto.ts
Normal file
File diff suppressed because it is too large
Load Diff
465
frontend/src/components/data-sync/wailsGateway.test.ts
Normal file
465
frontend/src/components/data-sync/wailsGateway.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createDataSyncTableMapping,
|
||||
createDataSyncTaskDraft,
|
||||
reviseDataSyncTask,
|
||||
} from './model';
|
||||
import {
|
||||
createWailsDataSyncWorkbenchGateway,
|
||||
type WailsDataSyncApi,
|
||||
} from './wailsGateway';
|
||||
import { encodeDataSyncJobDefinition } from './wailsDto';
|
||||
|
||||
const NOW = Date.parse('2030-08-08T00:00:20.000Z');
|
||||
|
||||
const taskFixture = () => {
|
||||
const draft = createDataSyncTaskDraft({
|
||||
id: 'persisted-task',
|
||||
kind: 'reconcile',
|
||||
name: 'Orders sync',
|
||||
now: '2030-08-08T00:00:00.000Z',
|
||||
});
|
||||
return reviseDataSyncTask(draft, {
|
||||
lifecycle: 'ready',
|
||||
source: {
|
||||
connectionId: 'source-id',
|
||||
connectionName: 'Source',
|
||||
type: 'mysql',
|
||||
database: 'sales',
|
||||
schema: '',
|
||||
},
|
||||
target: {
|
||||
connectionId: 'target-id',
|
||||
connectionName: 'Target',
|
||||
type: 'postgresql',
|
||||
database: 'warehouse',
|
||||
schema: 'ods',
|
||||
},
|
||||
mappings: [
|
||||
{
|
||||
...createDataSyncTableMapping('map-1', 'orders', 'ods.orders'),
|
||||
keyColumns: ['id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const success = (data?: unknown) => ({ success: true, message: '', data });
|
||||
|
||||
const apiFixture = (
|
||||
overrides: Partial<WailsDataSyncApi> = {},
|
||||
): WailsDataSyncApi => ({
|
||||
GetSavedConnections: vi.fn(async () => [
|
||||
{
|
||||
id: 'source-id',
|
||||
name: 'Source',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: 'sanitized-host',
|
||||
user: '',
|
||||
password: '',
|
||||
readOnly: false,
|
||||
protection: {},
|
||||
},
|
||||
},
|
||||
]),
|
||||
DataSyncDatabaseList: vi.fn(async () =>
|
||||
success([{ Database: 'sales' }]),
|
||||
),
|
||||
DataSyncObjectList: vi.fn(async () => success([{ Table: 'orders' }])),
|
||||
DataSyncFieldList: vi.fn(async () =>
|
||||
success([{ name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI' }]),
|
||||
),
|
||||
DataSyncCapabilityResolve: vi.fn(async () =>
|
||||
success({
|
||||
supportLevel: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
}),
|
||||
),
|
||||
DataSyncCDCAdapterList: vi.fn(async () => success(['mongodb-change-stream'])),
|
||||
DataSyncCDCProbe: vi.fn(async () =>
|
||||
success({
|
||||
adapter: 'mongodb-change-stream',
|
||||
supported: true,
|
||||
ready: true,
|
||||
reason: '',
|
||||
}),
|
||||
),
|
||||
DataSyncCheckpointGet: vi.fn(async () => ({
|
||||
success: false,
|
||||
message: 'data sync job record not found',
|
||||
})),
|
||||
DataSyncCheckpointReset: vi.fn(async () => success()),
|
||||
DataSyncErrorRowDiscard: vi.fn(async () => success()),
|
||||
DataSyncErrorRowList: vi.fn(async () => success([])),
|
||||
DataSyncErrorRowRetry: vi.fn(async () => success()),
|
||||
DataSyncJobApprovalBegin: vi.fn(async () =>
|
||||
success({
|
||||
challenge: 'server-challenge',
|
||||
notBefore: NOW + 10_000,
|
||||
expiresAt: NOW + 120_000,
|
||||
}),
|
||||
),
|
||||
DataSyncJobApprove: vi.fn(async () =>
|
||||
success({
|
||||
token: 'one-time-token',
|
||||
expiresAt: NOW + 600_000,
|
||||
}),
|
||||
),
|
||||
DataSyncJobList: vi.fn(async () => success([])),
|
||||
DataSyncJobPreflight: vi.fn(async (definition) =>
|
||||
success({
|
||||
status: 'passed',
|
||||
definition,
|
||||
definitionHash: 'definition-hash',
|
||||
approvalRequired: true,
|
||||
capability: {
|
||||
supportLevel: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
},
|
||||
issues: [],
|
||||
checkedAt: NOW,
|
||||
}),
|
||||
),
|
||||
DataSyncJobSave: vi.fn(async (definition) => success(definition)),
|
||||
DataSyncRunCancel: vi.fn(async () => success()),
|
||||
DataSyncRunList: vi.fn(async () => success([])),
|
||||
DataSyncRunResume: vi.fn(async () => success(runFixture('resume-run', 'resume'))),
|
||||
DataSyncRunRetry: vi.fn(async () => success(runFixture('retry-run', 'retry'))),
|
||||
DataSyncRunStart: vi.fn(async () => success(runFixture('run-1', 'manual'))),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const runFixture = (id: string, trigger: string) => ({
|
||||
id,
|
||||
jobId: 'persisted-task',
|
||||
trigger,
|
||||
status: 'queued',
|
||||
attempt: 1,
|
||||
resumable: false,
|
||||
message: '',
|
||||
queuedAt: NOW,
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
rowsInserted: 0,
|
||||
rowsUpdated: 0,
|
||||
rowsDeleted: 0,
|
||||
rowsFailed: 0,
|
||||
});
|
||||
|
||||
const errorRowFixture = (status = 'pending') => ({
|
||||
id: 'error-row-1',
|
||||
runId: 'run-1',
|
||||
jobId: 'persisted-task',
|
||||
sourceTable: 'orders',
|
||||
targetTable: 'ods.orders',
|
||||
operation: 'insert',
|
||||
payloadPolicy: 'full',
|
||||
error: 'invalid timestamp',
|
||||
status,
|
||||
});
|
||||
|
||||
const preflightData = (task = taskFixture(), approvalRequired = true) => ({
|
||||
status: 'passed',
|
||||
definition: encodeDataSyncJobDefinition(task),
|
||||
definitionHash: 'definition-hash',
|
||||
approvalRequired,
|
||||
capability: {
|
||||
supportLevel: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
},
|
||||
issues: [],
|
||||
checkedAt: NOW,
|
||||
});
|
||||
|
||||
describe('real Wails data sync gateway', () => {
|
||||
it('returns localized-form validation codes before calling backend preflight', async () => {
|
||||
const base = taskFixture();
|
||||
const task = reviseDataSyncTask(base, {
|
||||
mappings: [createDataSyncTableMapping('map-empty', '', '')],
|
||||
});
|
||||
const api = apiFixture();
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => NOW });
|
||||
|
||||
const preflight = await gateway.preflightTask(task);
|
||||
|
||||
expect(preflight).toMatchObject({
|
||||
status: 'blocked',
|
||||
definitionHash: '',
|
||||
approvalRequired: false,
|
||||
});
|
||||
expect(preflight.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'source_object_required',
|
||||
'target_object_required',
|
||||
'key_columns_required',
|
||||
]),
|
||||
);
|
||||
expect(api.DataSyncJobPreflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses only saved connection IDs for metadata and never forwards sanitized configs', async () => {
|
||||
const api = apiFixture();
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => NOW });
|
||||
const connections = await gateway.listSavedConnections();
|
||||
await gateway.listDatabases('source-id');
|
||||
await gateway.listObjects({
|
||||
connectionId: 'source-id',
|
||||
connectionName: 'Source',
|
||||
type: 'mysql',
|
||||
database: 'sales',
|
||||
schema: 'public',
|
||||
});
|
||||
await gateway.listFields(
|
||||
{
|
||||
connectionId: 'source-id',
|
||||
connectionName: 'Source',
|
||||
type: 'mysql',
|
||||
database: 'sales',
|
||||
schema: 'public',
|
||||
},
|
||||
'public.orders',
|
||||
);
|
||||
|
||||
expect(connections[0]).toEqual({
|
||||
id: 'source-id',
|
||||
name: 'Source',
|
||||
type: 'mysql',
|
||||
readable: true,
|
||||
writable: true,
|
||||
});
|
||||
expect(api.DataSyncDatabaseList).toHaveBeenCalledWith('source-id');
|
||||
expect(api.DataSyncObjectList).toHaveBeenCalledWith(
|
||||
'source-id',
|
||||
'sales',
|
||||
'public',
|
||||
);
|
||||
expect(api.DataSyncFieldList).toHaveBeenCalledWith(
|
||||
'source-id',
|
||||
'sales',
|
||||
'public',
|
||||
'orders',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a structured blocked preflight while rejecting malformed success payloads', async () => {
|
||||
const task = taskFixture();
|
||||
const blockedApi = apiFixture({
|
||||
DataSyncJobPreflight: vi.fn(async (definition) => ({
|
||||
success: false,
|
||||
message: 'blocked',
|
||||
data: {
|
||||
...preflightData(task, false),
|
||||
status: 'blocked',
|
||||
definition,
|
||||
issues: [
|
||||
{
|
||||
code: 'route_unsupported',
|
||||
severity: 'blocker',
|
||||
stage: 'endpoints',
|
||||
message: 'route unavailable',
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
});
|
||||
const blockedGateway = createWailsDataSyncWorkbenchGateway({
|
||||
api: blockedApi,
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(await blockedGateway.preflightTask(task)).toMatchObject({
|
||||
status: 'blocked',
|
||||
issues: [{ message: 'route unavailable' }],
|
||||
});
|
||||
|
||||
const malformedGateway = createWailsDataSyncWorkbenchGateway({
|
||||
api: apiFixture({
|
||||
DataSyncJobList: vi.fn(async () => ({ success: true, message: '' })),
|
||||
}),
|
||||
now: () => NOW,
|
||||
});
|
||||
await expect(malformedGateway.listTasks()).rejects.toThrow('omitted data');
|
||||
});
|
||||
|
||||
it('mints a memory-only token only after countdown and consumes it once on start', async () => {
|
||||
const task = taskFixture();
|
||||
const api = apiFixture();
|
||||
let clock = NOW;
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => clock });
|
||||
const preflight = await gateway.preflightTask(task);
|
||||
|
||||
const challenge = await gateway.beginApproval(task, preflight);
|
||||
expect(challenge).toEqual({
|
||||
definitionHash: 'definition-hash',
|
||||
notBefore: new Date(NOW + 10_000).toISOString(),
|
||||
expiresAt: new Date(NOW + 120_000).toISOString(),
|
||||
});
|
||||
expect(challenge).not.toHaveProperty('challenge');
|
||||
await expect(
|
||||
gateway.approveTask(task, preflight),
|
||||
).rejects.toThrow('countdown is incomplete');
|
||||
|
||||
await gateway.beginApproval(task, preflight);
|
||||
clock = NOW + 10_000;
|
||||
const grant = await gateway.approveTask(task, preflight);
|
||||
expect(grant).toEqual({
|
||||
definitionHash: 'definition-hash',
|
||||
expiresAt: new Date(NOW + 600_000).toISOString(),
|
||||
});
|
||||
expect(grant).not.toHaveProperty('token');
|
||||
expect(api.DataSyncJobApprove).toHaveBeenCalledTimes(1);
|
||||
const approvedDefinition = vi.mocked(api.DataSyncJobApprove).mock.calls[0][0];
|
||||
expect(approvedDefinition.approval).toBeUndefined();
|
||||
expect(api.DataSyncJobApprove).toHaveBeenCalledWith(
|
||||
approvedDefinition,
|
||||
'server-challenge',
|
||||
);
|
||||
|
||||
await expect(gateway.startTask(task, preflight)).resolves.toMatchObject({
|
||||
id: 'run-1',
|
||||
status: 'queued',
|
||||
});
|
||||
expect(api.DataSyncRunStart).toHaveBeenCalledWith(
|
||||
task.id,
|
||||
task.revision,
|
||||
'one-time-token',
|
||||
);
|
||||
await expect(gateway.startTask(task, preflight)).rejects.toThrow(
|
||||
'explicit production approval is required',
|
||||
);
|
||||
expect(api.DataSyncRunStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('binds authorization to the exact local signature and runnable lifecycle', async () => {
|
||||
const task = taskFixture();
|
||||
const api = apiFixture();
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => NOW });
|
||||
const preflight = await gateway.preflightTask(task);
|
||||
await gateway.beginApproval(task, preflight);
|
||||
|
||||
const paused = reviseDataSyncTask(task, { lifecycle: 'paused' });
|
||||
await expect(gateway.startTask(paused, preflight)).rejects.toThrow(
|
||||
'only ready or enabled tasks can run',
|
||||
);
|
||||
expect(api.DataSyncRunStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows pausing without production approval while ready/enabled saves stay guarded', async () => {
|
||||
const task = taskFixture();
|
||||
const api = apiFixture();
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => NOW });
|
||||
const paused = reviseDataSyncTask(task, { lifecycle: 'paused' });
|
||||
|
||||
await expect(gateway.saveTask(paused)).resolves.toMatchObject({
|
||||
lifecycle: 'paused',
|
||||
});
|
||||
expect(api.DataSyncJobSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lifecycle: 'paused' }),
|
||||
'',
|
||||
);
|
||||
await expect(gateway.saveTask(task)).rejects.toThrow(
|
||||
'run preflight again',
|
||||
);
|
||||
});
|
||||
|
||||
it('retries only a listed full-payload row at the current task revision', async () => {
|
||||
const task = taskFixture();
|
||||
const api = apiFixture({
|
||||
DataSyncJobList: vi.fn(async () =>
|
||||
success([encodeDataSyncJobDefinition(task)]),
|
||||
),
|
||||
DataSyncErrorRowList: vi.fn(async () => success([errorRowFixture()])),
|
||||
DataSyncErrorRowRetry: vi.fn(async () =>
|
||||
success(errorRowFixture('resolved')),
|
||||
),
|
||||
});
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => NOW });
|
||||
|
||||
await gateway.listTasks();
|
||||
const [row] = await gateway.listErrorRows('run-1');
|
||||
expect(gateway.capabilities.errorRowRetry).toBe(true);
|
||||
expect(row).toMatchObject({
|
||||
taskId: task.id,
|
||||
retryable: true,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await expect(gateway.retryErrorRow(row.id)).resolves.toMatchObject({
|
||||
id: row.id,
|
||||
status: 'resolved',
|
||||
});
|
||||
expect(api.DataSyncErrorRowRetry).toHaveBeenCalledWith(
|
||||
row.id,
|
||||
task.revision,
|
||||
'',
|
||||
);
|
||||
await expect(gateway.retryErrorRow(row.id)).rejects.toThrow(
|
||||
'capture its full payload before retrying',
|
||||
);
|
||||
expect(api.DataSyncErrorRowRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('consumes an explicit production approval token once for error-row retry', async () => {
|
||||
const task = taskFixture();
|
||||
let clock = NOW;
|
||||
const api = apiFixture({
|
||||
DataSyncJobList: vi.fn(async () =>
|
||||
success([encodeDataSyncJobDefinition(task)]),
|
||||
),
|
||||
DataSyncErrorRowList: vi.fn(async () => success([errorRowFixture()])),
|
||||
DataSyncErrorRowRetry: vi.fn(async () =>
|
||||
success(errorRowFixture('resolved')),
|
||||
),
|
||||
});
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => clock });
|
||||
|
||||
await gateway.listTasks();
|
||||
const preflight = await gateway.preflightTask(task);
|
||||
await gateway.beginApproval(task, preflight);
|
||||
clock = NOW + 10_000;
|
||||
await gateway.approveTask(task, preflight);
|
||||
const [row] = await gateway.listErrorRows('run-1');
|
||||
|
||||
await gateway.retryErrorRow(row.id);
|
||||
expect(api.DataSyncErrorRowRetry).toHaveBeenCalledWith(
|
||||
row.id,
|
||||
task.revision,
|
||||
'one-time-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the paused task revision when resetting a checkpoint and refreshes the cached task', async () => {
|
||||
const task = { ...taskFixture(), lifecycle: 'paused' as const };
|
||||
const saved = { ...task, revision: task.revision + 1 };
|
||||
const api = apiFixture({
|
||||
DataSyncJobList: vi.fn(async () =>
|
||||
success([encodeDataSyncJobDefinition(task)]),
|
||||
),
|
||||
DataSyncCheckpointReset: vi.fn(async () =>
|
||||
success(encodeDataSyncJobDefinition(saved)),
|
||||
),
|
||||
});
|
||||
const gateway = createWailsDataSyncWorkbenchGateway({ api, now: () => NOW });
|
||||
await gateway.listTasks();
|
||||
|
||||
await expect(
|
||||
gateway.resetCheckpoint(task.id, task.revision),
|
||||
).resolves.toMatchObject({
|
||||
id: task.id,
|
||||
lifecycle: 'paused',
|
||||
revision: saved.revision,
|
||||
});
|
||||
expect(api.DataSyncCheckpointReset).toHaveBeenCalledWith(
|
||||
task.id,
|
||||
task.revision,
|
||||
);
|
||||
await expect(
|
||||
gateway.resetCheckpoint(task.id, task.revision),
|
||||
).rejects.toThrow('current paused task revision');
|
||||
expect(api.DataSyncCheckpointReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
791
frontend/src/components/data-sync/wailsGateway.ts
Normal file
791
frontend/src/components/data-sync/wailsGateway.ts
Normal file
@@ -0,0 +1,791 @@
|
||||
import * as WailsApp from '../../../wailsjs/go/app/App';
|
||||
import { syncjob } from '../../../wailsjs/go/models';
|
||||
|
||||
import type { DataSyncWorkbenchGateway } from './gateway';
|
||||
import type {
|
||||
DataSyncApprovalChallenge,
|
||||
DataSyncApprovalGrant,
|
||||
DataSyncCheckpointSummary,
|
||||
DataSyncRouteCapability,
|
||||
DataSyncRunRecord,
|
||||
DataSyncTaskDefinition,
|
||||
} from './model';
|
||||
import { validateDataSyncTask } from './model';
|
||||
import {
|
||||
cdcSourceFromProbe,
|
||||
decodeCDCAdapters,
|
||||
decodeCDCProbe,
|
||||
decodeCheckpoint,
|
||||
decodeDataSyncApproval,
|
||||
decodeDataSyncApprovalChallenge,
|
||||
decodeDataSyncJobDefinition,
|
||||
decodeDataSyncPreflightQuery,
|
||||
decodeDatabaseMetadata,
|
||||
decodeErrorRow,
|
||||
decodeFieldMetadata,
|
||||
decodeObjectMetadata,
|
||||
decodeRouteCapability,
|
||||
decodeRunRecord,
|
||||
decodeSavedConnectionViews,
|
||||
decodeScheduleSummary,
|
||||
encodeDataSyncJobDefinition,
|
||||
isLocalDataSyncTaskId,
|
||||
requireWailsCommandSuccess,
|
||||
requireWailsQueryData,
|
||||
DataSyncGatewayProtocolError,
|
||||
type WailsDataSyncJobDefinition,
|
||||
type WailsQueryResultLike,
|
||||
} from './wailsDto';
|
||||
|
||||
type QueryResultPromise = Promise<WailsQueryResultLike>;
|
||||
|
||||
/** Narrow seam around Wails so protocol handling can be tested without a runtime. */
|
||||
export interface WailsDataSyncApi {
|
||||
GetSavedConnections(): Promise<unknown>;
|
||||
DataSyncDatabaseList(connectionId: string): QueryResultPromise;
|
||||
DataSyncObjectList(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncFieldList(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
objectName: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncCapabilityResolve(
|
||||
sourceConnectionId: string,
|
||||
sourceDatabase: string,
|
||||
sourceSchema: string,
|
||||
targetConnectionId: string,
|
||||
targetDatabase: string,
|
||||
targetSchema: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncCDCAdapterList(): QueryResultPromise;
|
||||
DataSyncCDCProbe(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
adapter: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncCheckpointGet(taskId: string): QueryResultPromise;
|
||||
DataSyncCheckpointReset(
|
||||
taskId: string,
|
||||
expectedJobRevision: number,
|
||||
): QueryResultPromise;
|
||||
DataSyncErrorRowDiscard(errorRowId: string): QueryResultPromise;
|
||||
DataSyncErrorRowRetry(
|
||||
errorRowId: string,
|
||||
expectedJobRevision: number,
|
||||
approvalToken: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncErrorRowList(
|
||||
runId: string,
|
||||
status: string,
|
||||
limit: number,
|
||||
): QueryResultPromise;
|
||||
DataSyncJobApprovalBegin(definition: syncjob.JobDefinition): QueryResultPromise;
|
||||
DataSyncJobApprove(
|
||||
definition: syncjob.JobDefinition,
|
||||
challenge: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncJobList(): QueryResultPromise;
|
||||
DataSyncJobPreflight(definition: syncjob.JobDefinition): QueryResultPromise;
|
||||
DataSyncJobSave(
|
||||
definition: syncjob.JobDefinition,
|
||||
approvalToken: string,
|
||||
): QueryResultPromise;
|
||||
DataSyncRunCancel(runId: string): QueryResultPromise;
|
||||
DataSyncRunList(taskId: string, limit: number): QueryResultPromise;
|
||||
DataSyncRunResume(runId: string): QueryResultPromise;
|
||||
DataSyncRunRetry(runId: string): QueryResultPromise;
|
||||
DataSyncRunStart(
|
||||
taskId: string,
|
||||
expectedRevision: number,
|
||||
approvalToken: string,
|
||||
): QueryResultPromise;
|
||||
}
|
||||
|
||||
type GatewayOptions = {
|
||||
api?: WailsDataSyncApi;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type CachedPreflight = {
|
||||
taskRevision: number;
|
||||
taskSignature: string;
|
||||
definitionHash: string;
|
||||
approvalRequired: boolean;
|
||||
canExecute: boolean;
|
||||
definition: WailsDataSyncJobDefinition;
|
||||
};
|
||||
|
||||
type ApprovalToken = {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
definitionHash: string;
|
||||
taskSignature: string;
|
||||
};
|
||||
|
||||
type ApprovalChallenge = {
|
||||
challenge: string;
|
||||
notBefore: string;
|
||||
expiresAt: string;
|
||||
definitionHash: string;
|
||||
taskSignature: string;
|
||||
};
|
||||
|
||||
const UNKNOWN_CAPABILITY: DataSyncRouteCapability = {
|
||||
level: 'unknown',
|
||||
canExecute: false,
|
||||
supportsAutoCreate: false,
|
||||
supportsCdc: false,
|
||||
};
|
||||
|
||||
const asApi = (): WailsDataSyncApi =>
|
||||
WailsApp as unknown as WailsDataSyncApi;
|
||||
|
||||
const asJobDefinition = (
|
||||
value: WailsDataSyncJobDefinition,
|
||||
): syncjob.JobDefinition => new syncjob.JobDefinition(value);
|
||||
|
||||
const taskSignature = (
|
||||
task: DataSyncTaskDefinition,
|
||||
previous?: WailsDataSyncJobDefinition,
|
||||
): string => JSON.stringify(encodeDataSyncJobDefinition(task, previous));
|
||||
|
||||
const sanitizedWireDefinition = (
|
||||
value: unknown,
|
||||
): WailsDataSyncJobDefinition => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new DataSyncGatewayProtocolError('job', 'expected object');
|
||||
}
|
||||
const sanitized = { ...(value as WailsDataSyncJobDefinition) };
|
||||
// Backend approval evidence is never retained or reflected into the UI.
|
||||
delete sanitized.approval;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const queryFailureMessage = (result: WailsQueryResultLike): string =>
|
||||
typeof result?.message === 'string' ? result.message.trim() : '';
|
||||
|
||||
const isCheckpointMissing = (result: WailsQueryResultLike): boolean =>
|
||||
result?.success === false &&
|
||||
queryFailureMessage(result) === 'data sync job record not found';
|
||||
|
||||
const stripEndpointSchema = (schema: string, objectName: string): string => {
|
||||
const prefix = `${schema.trim()}.`;
|
||||
return prefix !== '.' && objectName.trim().startsWith(prefix)
|
||||
? objectName.trim().slice(prefix.length)
|
||||
: objectName.trim();
|
||||
};
|
||||
|
||||
const isCurrentPreflight = (
|
||||
cached: CachedPreflight | undefined,
|
||||
task: DataSyncTaskDefinition,
|
||||
previous?: WailsDataSyncJobDefinition,
|
||||
): cached is CachedPreflight =>
|
||||
Boolean(
|
||||
cached &&
|
||||
cached.taskRevision === task.revision &&
|
||||
cached.taskSignature === taskSignature(task, previous),
|
||||
);
|
||||
|
||||
export const createWailsDataSyncWorkbenchGateway = (
|
||||
options: GatewayOptions = {},
|
||||
): DataSyncWorkbenchGateway => {
|
||||
const api = options.api || asApi();
|
||||
const now = options.now || Date.now;
|
||||
const wireJobs = new Map<string, WailsDataSyncJobDefinition>();
|
||||
const taskNames = new Map<string, string>();
|
||||
const preflights = new Map<string, CachedPreflight>();
|
||||
const approvalTokens = new Map<string, ApprovalToken>();
|
||||
const approvalChallenges = new Map<string, ApprovalChallenge>();
|
||||
const errorRows = new Map<string, ReturnType<typeof decodeErrorRow>>();
|
||||
const connectionTypes = new Map<string, string>();
|
||||
let tasksLoaded = false;
|
||||
|
||||
const decodeRuns = (value: unknown): DataSyncRunRecord[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new DataSyncGatewayProtocolError('DataSyncRunList.data', 'expected array');
|
||||
}
|
||||
return value.map((run) => decodeRunRecord(run, taskNames));
|
||||
};
|
||||
|
||||
const takeApprovalToken = (
|
||||
task: DataSyncTaskDefinition,
|
||||
preflight: CachedPreflight,
|
||||
): string => {
|
||||
const approval = approvalTokens.get(task.id);
|
||||
if (
|
||||
!approval ||
|
||||
approval.definitionHash !== preflight.definitionHash ||
|
||||
approval.taskSignature !== preflight.taskSignature ||
|
||||
Date.parse(approval.expiresAt) <= now()
|
||||
) {
|
||||
approvalTokens.delete(task.id);
|
||||
return '';
|
||||
}
|
||||
// The backend token is one-time. Remove it before crossing the boundary so
|
||||
// retries can never accidentally reuse a token with uncertain outcome.
|
||||
approvalTokens.delete(task.id);
|
||||
return approval.token;
|
||||
};
|
||||
|
||||
const requireCurrentPreflight = (
|
||||
task: DataSyncTaskDefinition,
|
||||
): CachedPreflight => {
|
||||
const previous = wireJobs.get(task.id);
|
||||
const cached = preflights.get(task.id);
|
||||
if (!isCurrentPreflight(cached, task, previous)) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'data sync preflight',
|
||||
'task definition changed; run preflight again',
|
||||
);
|
||||
}
|
||||
return cached;
|
||||
};
|
||||
|
||||
const getCheckpoint = async (
|
||||
taskId: string,
|
||||
): Promise<DataSyncCheckpointSummary | null> => {
|
||||
if (!taskId.trim() || isLocalDataSyncTaskId(taskId)) return null;
|
||||
const result = await api.DataSyncCheckpointGet(taskId);
|
||||
if (isCheckpointMissing(result)) return null;
|
||||
return decodeCheckpoint(requireWailsQueryData(result, 'DataSyncCheckpointGet'));
|
||||
};
|
||||
|
||||
const gateway: DataSyncWorkbenchGateway = {
|
||||
capabilities: { errorRowRetry: true },
|
||||
async listSavedConnections() {
|
||||
const connections = decodeSavedConnectionViews(
|
||||
await api.GetSavedConnections(),
|
||||
);
|
||||
connectionTypes.clear();
|
||||
connections.forEach((connection) => {
|
||||
connectionTypes.set(connection.id, connection.type);
|
||||
});
|
||||
return connections;
|
||||
},
|
||||
|
||||
async listDatabases(connectionId) {
|
||||
return decodeDatabaseMetadata(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncDatabaseList(connectionId),
|
||||
'DataSyncDatabaseList',
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listObjects(endpoint) {
|
||||
return decodeObjectMetadata(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncObjectList(
|
||||
endpoint.connectionId,
|
||||
endpoint.database,
|
||||
endpoint.schema,
|
||||
),
|
||||
'DataSyncObjectList',
|
||||
),
|
||||
endpoint.type || connectionTypes.get(endpoint.connectionId) || '',
|
||||
);
|
||||
},
|
||||
|
||||
async listFields(endpoint, objectName) {
|
||||
return decodeFieldMetadata(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncFieldList(
|
||||
endpoint.connectionId,
|
||||
endpoint.database,
|
||||
endpoint.schema,
|
||||
stripEndpointSchema(endpoint.schema, objectName),
|
||||
),
|
||||
'DataSyncFieldList',
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listTasks() {
|
||||
const value = requireWailsQueryData(
|
||||
await api.DataSyncJobList(),
|
||||
'DataSyncJobList',
|
||||
);
|
||||
if (!Array.isArray(value)) {
|
||||
throw new DataSyncGatewayProtocolError('DataSyncJobList.data', 'expected array');
|
||||
}
|
||||
wireJobs.clear();
|
||||
taskNames.clear();
|
||||
const tasks = value.map((item) => {
|
||||
const wire = sanitizedWireDefinition(item);
|
||||
const task = decodeDataSyncJobDefinition(wire);
|
||||
wireJobs.set(task.id, wire);
|
||||
taskNames.set(task.id, task.name);
|
||||
return task;
|
||||
});
|
||||
tasksLoaded = true;
|
||||
return tasks;
|
||||
},
|
||||
|
||||
async saveTask(task) {
|
||||
const previous = wireJobs.get(task.id);
|
||||
let definition = encodeDataSyncJobDefinition(task, previous);
|
||||
let token = '';
|
||||
if (task.lifecycle === 'ready' || task.lifecycle === 'enabled') {
|
||||
const preflight = requireCurrentPreflight(task);
|
||||
definition = preflight.definition;
|
||||
if (preflight.approvalRequired) {
|
||||
token = takeApprovalToken(task, preflight);
|
||||
if (!token) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncJobSave',
|
||||
'explicit production approval is required',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const savedValue = requireWailsQueryData(
|
||||
await api.DataSyncJobSave(asJobDefinition(definition), token),
|
||||
'DataSyncJobSave',
|
||||
);
|
||||
const savedWire = sanitizedWireDefinition(savedValue);
|
||||
const saved = decodeDataSyncJobDefinition(savedWire);
|
||||
wireJobs.delete(task.id);
|
||||
taskNames.delete(task.id);
|
||||
preflights.delete(task.id);
|
||||
wireJobs.set(saved.id, savedWire);
|
||||
taskNames.set(saved.id, saved.name);
|
||||
return saved;
|
||||
},
|
||||
|
||||
async resolveCapability(task) {
|
||||
if (!task.source.connectionId || !task.target.connectionId) {
|
||||
return { ...UNKNOWN_CAPABILITY };
|
||||
}
|
||||
const base = decodeRouteCapability(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncCapabilityResolve(
|
||||
task.source.connectionId,
|
||||
task.source.database,
|
||||
task.source.schema,
|
||||
task.target.connectionId,
|
||||
task.target.database,
|
||||
task.target.schema,
|
||||
),
|
||||
'DataSyncCapabilityResolve',
|
||||
),
|
||||
);
|
||||
if (task.kind !== 'cdc') return base;
|
||||
if (task.incremental.mode !== 'cdc' || !task.incremental.adapter) {
|
||||
return { ...base, canExecute: false, supportsAutoCreate: false, supportsCdc: false };
|
||||
}
|
||||
const probe = decodeCDCProbe(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncCDCProbe(
|
||||
task.source.connectionId,
|
||||
task.source.database,
|
||||
task.source.schema,
|
||||
task.incremental.adapter,
|
||||
),
|
||||
'DataSyncCDCProbe',
|
||||
),
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
canExecute: base.canExecute && probe.supported && probe.ready,
|
||||
supportsAutoCreate: false,
|
||||
supportsCdc: probe.supported,
|
||||
};
|
||||
},
|
||||
|
||||
async preflightTask(task) {
|
||||
const localIssues = validateDataSyncTask(task);
|
||||
if (localIssues.some((issue) => issue.severity === 'blocker')) {
|
||||
preflights.delete(task.id);
|
||||
approvalChallenges.delete(task.id);
|
||||
approvalTokens.delete(task.id);
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskRevision: task.revision,
|
||||
status: 'blocked',
|
||||
issues: localIssues,
|
||||
definitionHash: '',
|
||||
approvalRequired: false,
|
||||
approvalSatisfied: false,
|
||||
checkedAt: new Date(now()).toISOString(),
|
||||
};
|
||||
}
|
||||
const previous = wireJobs.get(task.id);
|
||||
const input = encodeDataSyncJobDefinition(task, previous);
|
||||
const decoded = decodeDataSyncPreflightQuery(
|
||||
await api.DataSyncJobPreflight(asJobDefinition(input)),
|
||||
task,
|
||||
);
|
||||
if (task.kind === 'compare' && !decoded.capability.canExecute) {
|
||||
decoded.snapshot.status = 'blocked';
|
||||
decoded.snapshot.issues.push({
|
||||
id: 'compare-capability-unsupported',
|
||||
code: 'compare_route_unsupported',
|
||||
severity: 'blocker',
|
||||
stage: 'endpoints',
|
||||
message: 'the selected source-target route cannot execute compare tasks',
|
||||
});
|
||||
}
|
||||
const definition = sanitizedWireDefinition(decoded.definition);
|
||||
approvalChallenges.delete(task.id);
|
||||
approvalTokens.delete(task.id);
|
||||
preflights.set(task.id, {
|
||||
taskRevision: task.revision,
|
||||
taskSignature: JSON.stringify(input),
|
||||
definitionHash: decoded.snapshot.definitionHash,
|
||||
approvalRequired: decoded.snapshot.approvalRequired,
|
||||
canExecute: decoded.capability.canExecute,
|
||||
definition,
|
||||
});
|
||||
return decoded.snapshot;
|
||||
},
|
||||
|
||||
async beginApproval(task, preflight): Promise<DataSyncApprovalChallenge> {
|
||||
const cached = requireCurrentPreflight(task);
|
||||
if (
|
||||
!preflight.approvalRequired ||
|
||||
preflight.status === 'blocked' ||
|
||||
cached.definitionHash !== preflight.definitionHash
|
||||
) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncJobApprovalBegin',
|
||||
'approval does not match the current passed preflight',
|
||||
);
|
||||
}
|
||||
const challenge = decodeDataSyncApprovalChallenge(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncJobApprovalBegin(asJobDefinition(cached.definition)),
|
||||
'DataSyncJobApprovalBegin',
|
||||
),
|
||||
);
|
||||
if (Date.parse(challenge.expiresAt) <= now()) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncJobApprovalBegin',
|
||||
'approval challenge expired before it could be stored',
|
||||
);
|
||||
}
|
||||
approvalChallenges.set(task.id, {
|
||||
...challenge,
|
||||
definitionHash: preflight.definitionHash,
|
||||
taskSignature: cached.taskSignature,
|
||||
});
|
||||
return {
|
||||
definitionHash: preflight.definitionHash,
|
||||
notBefore: challenge.notBefore,
|
||||
expiresAt: challenge.expiresAt,
|
||||
};
|
||||
},
|
||||
|
||||
async approveTask(task, preflight): Promise<DataSyncApprovalGrant> {
|
||||
const cached = requireCurrentPreflight(task);
|
||||
const challenge = approvalChallenges.get(task.id);
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.definitionHash !== preflight.definitionHash ||
|
||||
challenge.definitionHash !== cached.definitionHash ||
|
||||
challenge.taskSignature !== cached.taskSignature ||
|
||||
Date.parse(challenge.notBefore) > now() ||
|
||||
Date.parse(challenge.expiresAt) <= now()
|
||||
) {
|
||||
approvalChallenges.delete(task.id);
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncJobApprove',
|
||||
'backend approval countdown is incomplete, expired, or stale',
|
||||
);
|
||||
}
|
||||
// The backend challenge is also one-time. Remove it before crossing the
|
||||
// boundary so an uncertain response can never be replayed.
|
||||
approvalChallenges.delete(task.id);
|
||||
const approved = decodeDataSyncApproval(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncJobApprove(
|
||||
asJobDefinition(cached.definition),
|
||||
challenge.challenge,
|
||||
),
|
||||
'DataSyncJobApprove',
|
||||
),
|
||||
);
|
||||
if (Date.parse(approved.expiresAt) <= now()) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncJobApprove',
|
||||
'approval token expired before it could be stored',
|
||||
);
|
||||
}
|
||||
approvalTokens.set(task.id, {
|
||||
...approved,
|
||||
definitionHash: preflight.definitionHash,
|
||||
taskSignature: cached.taskSignature,
|
||||
});
|
||||
return {
|
||||
definitionHash: preflight.definitionHash,
|
||||
expiresAt: approved.expiresAt,
|
||||
};
|
||||
},
|
||||
|
||||
async startTask(task, preflight) {
|
||||
if (isLocalDataSyncTaskId(task.id)) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncRunStart',
|
||||
'save the task before running it',
|
||||
);
|
||||
}
|
||||
if (task.lifecycle !== 'ready' && task.lifecycle !== 'enabled') {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncRunStart',
|
||||
'only ready or enabled tasks can run',
|
||||
);
|
||||
}
|
||||
const cached = requireCurrentPreflight(task);
|
||||
if (
|
||||
cached.definitionHash !== preflight.definitionHash ||
|
||||
preflight.status === 'blocked' ||
|
||||
!cached.canExecute
|
||||
) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncRunStart',
|
||||
'preflight is blocked or stale',
|
||||
);
|
||||
}
|
||||
let token = '';
|
||||
if (cached.approvalRequired) {
|
||||
token = takeApprovalToken(task, cached);
|
||||
if (!token) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncRunStart',
|
||||
'explicit production approval is required',
|
||||
);
|
||||
}
|
||||
}
|
||||
return decodeRunRecord(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncRunStart(task.id, task.revision, token),
|
||||
'DataSyncRunStart',
|
||||
),
|
||||
taskNames,
|
||||
);
|
||||
},
|
||||
|
||||
async listRuns(taskId) {
|
||||
return decodeRuns(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncRunList(taskId || '', 200),
|
||||
'DataSyncRunList',
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listErrorRows(runId) {
|
||||
const value = requireWailsQueryData(
|
||||
await api.DataSyncErrorRowList(runId, '', 500),
|
||||
'DataSyncErrorRowList',
|
||||
);
|
||||
if (!Array.isArray(value)) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncErrorRowList.data',
|
||||
'expected array',
|
||||
);
|
||||
}
|
||||
return value.map((item) => {
|
||||
const row = decodeErrorRow(item);
|
||||
errorRows.set(row.id, row);
|
||||
return row;
|
||||
});
|
||||
},
|
||||
|
||||
async listSchedules() {
|
||||
if (!tasksLoaded) await gateway.listTasks();
|
||||
return Array.from(wireJobs.values()).flatMap((job) => {
|
||||
const schedule = decodeScheduleSummary(job);
|
||||
return schedule ? [schedule] : [];
|
||||
});
|
||||
},
|
||||
|
||||
async listCdcAdapters() {
|
||||
return decodeCDCAdapters(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncCDCAdapterList(),
|
||||
'DataSyncCDCAdapterList',
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listCdcSources() {
|
||||
const tasks =
|
||||
!tasksLoaded
|
||||
? await gateway.listTasks()
|
||||
: Array.from(wireJobs.values()).map(decodeDataSyncJobDefinition);
|
||||
const cdcTasks = tasks.filter(
|
||||
(
|
||||
task,
|
||||
): task is DataSyncTaskDefinition & {
|
||||
incremental: Extract<DataSyncTaskDefinition['incremental'], { mode: 'cdc' }>;
|
||||
} => task.kind === 'cdc' && task.incremental.mode === 'cdc',
|
||||
);
|
||||
if (cdcTasks.length === 0) return [];
|
||||
let adapters: string[] = [];
|
||||
let adapterListError = '';
|
||||
try {
|
||||
adapters = await gateway.listCdcAdapters();
|
||||
} catch (error) {
|
||||
adapterListError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
return Promise.all(
|
||||
cdcTasks.map(async (task) => {
|
||||
let checkpoint: DataSyncCheckpointSummary | null = null;
|
||||
let checkpointError = '';
|
||||
try {
|
||||
checkpoint = await getCheckpoint(task.id);
|
||||
} catch (error) {
|
||||
checkpointError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
if (adapterListError) {
|
||||
return cdcSourceFromProbe(task, null, checkpoint, adapterListError);
|
||||
}
|
||||
if (!adapters.includes(task.incremental.adapter)) {
|
||||
return {
|
||||
...cdcSourceFromProbe(
|
||||
task,
|
||||
null,
|
||||
checkpoint,
|
||||
checkpointError || 'selected CDC adapter is not registered',
|
||||
),
|
||||
status: 'unsupported' as const,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const probe = decodeCDCProbe(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncCDCProbe(
|
||||
task.source.connectionId,
|
||||
task.source.database,
|
||||
task.source.schema,
|
||||
task.incremental.adapter,
|
||||
),
|
||||
'DataSyncCDCProbe',
|
||||
),
|
||||
);
|
||||
return cdcSourceFromProbe(task, probe, checkpoint, checkpointError);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return cdcSourceFromProbe(task, null, checkpoint, reason);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
getCheckpoint,
|
||||
|
||||
async resetCheckpoint(taskId, expectedJobRevision) {
|
||||
const previous = wireJobs.get(taskId);
|
||||
if (!previous) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncCheckpointReset',
|
||||
'the current task revision is unavailable; refresh tasks before resetting the checkpoint',
|
||||
);
|
||||
}
|
||||
const task = decodeDataSyncJobDefinition(previous);
|
||||
if (task.lifecycle !== 'paused' || task.revision !== expectedJobRevision) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncCheckpointReset',
|
||||
'checkpoint reset requires the current paused task revision',
|
||||
);
|
||||
}
|
||||
const savedWire = sanitizedWireDefinition(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncCheckpointReset(taskId, expectedJobRevision),
|
||||
'DataSyncCheckpointReset',
|
||||
),
|
||||
);
|
||||
const saved = decodeDataSyncJobDefinition(savedWire);
|
||||
wireJobs.set(saved.id, savedWire);
|
||||
taskNames.set(saved.id, saved.name);
|
||||
preflights.delete(taskId);
|
||||
approvalTokens.delete(taskId);
|
||||
approvalChallenges.delete(taskId);
|
||||
return saved;
|
||||
},
|
||||
|
||||
async cancelRun(runId) {
|
||||
requireWailsCommandSuccess(
|
||||
await api.DataSyncRunCancel(runId),
|
||||
'DataSyncRunCancel',
|
||||
);
|
||||
},
|
||||
|
||||
async resumeRun(runId) {
|
||||
return decodeRunRecord(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncRunResume(runId),
|
||||
'DataSyncRunResume',
|
||||
),
|
||||
taskNames,
|
||||
);
|
||||
},
|
||||
|
||||
async retryRun(runId) {
|
||||
return decodeRunRecord(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncRunRetry(runId),
|
||||
'DataSyncRunRetry',
|
||||
),
|
||||
taskNames,
|
||||
);
|
||||
},
|
||||
|
||||
async discardErrorRow(errorRowId) {
|
||||
requireWailsCommandSuccess(
|
||||
await api.DataSyncErrorRowDiscard(errorRowId),
|
||||
'DataSyncErrorRowDiscard',
|
||||
);
|
||||
},
|
||||
|
||||
async retryErrorRow(errorRowId) {
|
||||
const row = errorRows.get(errorRowId);
|
||||
if (!row || !row.retryable || row.status !== 'pending') {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncErrorRowRetry',
|
||||
'refresh the error row and capture its full payload before retrying',
|
||||
);
|
||||
}
|
||||
const previous = wireJobs.get(row.taskId);
|
||||
if (!previous) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncErrorRowRetry',
|
||||
'the current task revision is unavailable; refresh tasks before retrying',
|
||||
);
|
||||
}
|
||||
const task = decodeDataSyncJobDefinition(previous);
|
||||
const cached = preflights.get(task.id);
|
||||
let token = '';
|
||||
if (
|
||||
cached &&
|
||||
isCurrentPreflight(cached, task, previous) &&
|
||||
cached.approvalRequired &&
|
||||
approvalTokens.has(task.id)
|
||||
) {
|
||||
token = takeApprovalToken(task, cached);
|
||||
}
|
||||
const retried = decodeErrorRow(
|
||||
requireWailsQueryData(
|
||||
await api.DataSyncErrorRowRetry(errorRowId, task.revision, token),
|
||||
'DataSyncErrorRowRetry',
|
||||
),
|
||||
);
|
||||
if (retried.id !== row.id || retried.taskId !== row.taskId) {
|
||||
throw new DataSyncGatewayProtocolError(
|
||||
'DataSyncErrorRowRetry.data',
|
||||
'backend returned a different error row or task',
|
||||
);
|
||||
}
|
||||
errorRows.set(retried.id, retried);
|
||||
return retried;
|
||||
},
|
||||
};
|
||||
|
||||
return gateway;
|
||||
};
|
||||
57
frontend/wailsjs/go/app/App.d.ts
vendored
57
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -4,6 +4,7 @@ import {connection} from '../models';
|
||||
import {sqlaudit} from '../models';
|
||||
import {app} from '../models';
|
||||
import {sync} from '../models';
|
||||
import {syncjob} from '../models';
|
||||
import {jvm} from '../models';
|
||||
import {redis} from '../models';
|
||||
import {resultdiff} from '../models';
|
||||
@@ -118,8 +119,64 @@ export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
|
||||
|
||||
export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncCDCAdapterList():Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncCDCProbe(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncCapability(arg1:connection.ConnectionConfig,arg2:connection.ConnectionConfig):Promise<sync.MigrationCapability>;
|
||||
|
||||
export function DataSyncCapabilityResolve(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string,arg6:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncCheckpointGet(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncCheckpointReset(arg1:string,arg2:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncDatabaseList(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncErrorRowDiscard(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncErrorRowGet(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncErrorRowList(arg1:string,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncErrorRowRetry(arg1:string,arg2:number,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncFieldList(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobApprovalBegin(arg1:syncjob.JobDefinition):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobApprove(arg1:syncjob.JobDefinition,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobDelete(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobGet(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobList():Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobPreflight(arg1:syncjob.JobDefinition):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncJobSave(arg1:syncjob.JobDefinition,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncObjectList(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunCancel(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunEventList(arg1:string,arg2:number,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunGet(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunList(arg1:string,arg2:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunResume(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunRetry(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncRunStart(arg1:string,arg2:number,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSyncSchedulePreview(arg1:syncjob.JobDefinition,arg2:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DeleteConnection(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteQuery(arg1:string):Promise<void>;
|
||||
|
||||
@@ -222,10 +222,122 @@ export function DataSyncAnalyze(arg1) {
|
||||
return window['go']['app']['App']['DataSyncAnalyze'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncCDCAdapterList() {
|
||||
return window['go']['app']['App']['DataSyncCDCAdapterList']();
|
||||
}
|
||||
|
||||
export function DataSyncCDCProbe(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DataSyncCDCProbe'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DataSyncCapability(arg1, arg2) {
|
||||
return window['go']['app']['App']['DataSyncCapability'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DataSyncCapabilityResolve(arg1, arg2, arg3, arg4, arg5, arg6) {
|
||||
return window['go']['app']['App']['DataSyncCapabilityResolve'](arg1, arg2, arg3, arg4, arg5, arg6);
|
||||
}
|
||||
|
||||
export function DataSyncCheckpointGet(arg1) {
|
||||
return window['go']['app']['App']['DataSyncCheckpointGet'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncCheckpointReset(arg1, arg2) {
|
||||
return window['go']['app']['App']['DataSyncCheckpointReset'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DataSyncDatabaseList(arg1) {
|
||||
return window['go']['app']['App']['DataSyncDatabaseList'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncErrorRowDiscard(arg1) {
|
||||
return window['go']['app']['App']['DataSyncErrorRowDiscard'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncErrorRowGet(arg1) {
|
||||
return window['go']['app']['App']['DataSyncErrorRowGet'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncErrorRowList(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncErrorRowList'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSyncErrorRowRetry(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncErrorRowRetry'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSyncFieldList(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DataSyncFieldList'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DataSyncJobApprovalBegin(arg1) {
|
||||
return window['go']['app']['App']['DataSyncJobApprovalBegin'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncJobApprove(arg1, arg2) {
|
||||
return window['go']['app']['App']['DataSyncJobApprove'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DataSyncJobDelete(arg1) {
|
||||
return window['go']['app']['App']['DataSyncJobDelete'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncJobGet(arg1) {
|
||||
return window['go']['app']['App']['DataSyncJobGet'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncJobList() {
|
||||
return window['go']['app']['App']['DataSyncJobList']();
|
||||
}
|
||||
|
||||
export function DataSyncJobPreflight(arg1) {
|
||||
return window['go']['app']['App']['DataSyncJobPreflight'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncJobSave(arg1, arg2) {
|
||||
return window['go']['app']['App']['DataSyncJobSave'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DataSyncObjectList(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncObjectList'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSyncPreview(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSyncRunCancel(arg1) {
|
||||
return window['go']['app']['App']['DataSyncRunCancel'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncRunEventList(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncRunEventList'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSyncRunGet(arg1) {
|
||||
return window['go']['app']['App']['DataSyncRunGet'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncRunList(arg1, arg2) {
|
||||
return window['go']['app']['App']['DataSyncRunList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DataSyncRunResume(arg1) {
|
||||
return window['go']['app']['App']['DataSyncRunResume'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncRunRetry(arg1) {
|
||||
return window['go']['app']['App']['DataSyncRunRetry'](arg1);
|
||||
}
|
||||
|
||||
export function DataSyncRunStart(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncRunStart'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DataSyncSchedulePreview(arg1, arg2) {
|
||||
return window['go']['app']['App']['DataSyncSchedulePreview'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DeleteConnection(arg1) {
|
||||
return window['go']['app']['App']['DeleteConnection'](arg1);
|
||||
}
|
||||
|
||||
@@ -2704,7 +2704,163 @@ export namespace sqlaudit {
|
||||
}
|
||||
|
||||
export namespace sync {
|
||||
|
||||
|
||||
export class MigrationCapability {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
sourceModel: string;
|
||||
targetModel: string;
|
||||
planner: string;
|
||||
supportLevel: string;
|
||||
canExecute: boolean;
|
||||
supportsAutoCreate: boolean;
|
||||
supportsAutoAddColumns: boolean;
|
||||
requiresExistingTarget: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MigrationCapability(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourceType = source["sourceType"];
|
||||
this.targetType = source["targetType"];
|
||||
this.sourceModel = source["sourceModel"];
|
||||
this.targetModel = source["targetModel"];
|
||||
this.planner = source["planner"];
|
||||
this.supportLevel = source["supportLevel"];
|
||||
this.canExecute = source["canExecute"];
|
||||
this.supportsAutoCreate = source["supportsAutoCreate"];
|
||||
this.supportsAutoAddColumns = source["supportsAutoAddColumns"];
|
||||
this.requiresExistingTarget = source["requiresExistingTarget"];
|
||||
}
|
||||
}
|
||||
export class SyncValueTransform {
|
||||
type: string;
|
||||
args?: Record<string, string>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncValueTransform(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.args = source["args"];
|
||||
}
|
||||
}
|
||||
export class SyncDefaultValue {
|
||||
when?: string[];
|
||||
valueType?: string;
|
||||
value?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncDefaultValue(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.when = source["when"];
|
||||
this.valueType = source["valueType"];
|
||||
this.value = source["value"];
|
||||
}
|
||||
}
|
||||
export class SyncColumnMapping {
|
||||
source?: string;
|
||||
target?: string;
|
||||
drop?: boolean;
|
||||
default?: SyncDefaultValue;
|
||||
transforms?: SyncValueTransform[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncColumnMapping(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.source = source["source"];
|
||||
this.target = source["target"];
|
||||
this.drop = source["drop"];
|
||||
this.default = this.convertValues(source["default"], SyncDefaultValue);
|
||||
this.transforms = this.convertValues(source["transforms"], SyncValueTransform);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SyncObjectRef {
|
||||
catalog?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
name: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncObjectRef(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.catalog = source["catalog"];
|
||||
this.database = source["database"];
|
||||
this.schema = source["schema"];
|
||||
this.name = source["name"];
|
||||
}
|
||||
}
|
||||
export class SyncObjectMapping {
|
||||
id?: string;
|
||||
source: SyncObjectRef;
|
||||
target: SyncObjectRef;
|
||||
keyColumns?: string[];
|
||||
filter?: string;
|
||||
columns?: SyncColumnMapping[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncObjectMapping(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.source = this.convertValues(source["source"], SyncObjectRef);
|
||||
this.target = this.convertValues(source["target"], SyncObjectRef);
|
||||
this.keyColumns = source["keyColumns"];
|
||||
this.filter = source["filter"];
|
||||
this.columns = this.convertValues(source["columns"], SyncColumnMapping);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class TableOptions {
|
||||
insert?: boolean;
|
||||
update?: boolean;
|
||||
@@ -2743,11 +2899,14 @@ export namespace sync {
|
||||
createIndexes?: boolean;
|
||||
mongoCollectionName?: string;
|
||||
tableOptions?: Record<string, TableOptions>;
|
||||
|
||||
mappings?: SyncObjectMapping[];
|
||||
batchSize?: number;
|
||||
rowErrorPolicy?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncConfig(source);
|
||||
}
|
||||
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourceConfig = this.convertValues(source["sourceConfig"], connection.ConnectionConfig);
|
||||
@@ -2765,8 +2924,11 @@ export namespace sync {
|
||||
this.createIndexes = source["createIndexes"];
|
||||
this.mongoCollectionName = source["mongoCollectionName"];
|
||||
this.tableOptions = this.convertValues(source["tableOptions"], TableOptions, true);
|
||||
this.mappings = this.convertValues(source["mappings"], SyncObjectMapping);
|
||||
this.batchSize = source["batchSize"];
|
||||
this.rowErrorPolicy = source["rowErrorPolicy"];
|
||||
}
|
||||
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
@@ -2793,11 +2955,13 @@ export namespace sync {
|
||||
rowsInserted: number;
|
||||
rowsUpdated: number;
|
||||
rowsDeleted: number;
|
||||
|
||||
rowsSkipped?: number;
|
||||
cancelled?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SyncResult(source);
|
||||
}
|
||||
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.success = source["success"];
|
||||
@@ -2807,7 +2971,326 @@ export namespace sync {
|
||||
this.rowsInserted = source["rowsInserted"];
|
||||
this.rowsUpdated = source["rowsUpdated"];
|
||||
this.rowsDeleted = source["rowsDeleted"];
|
||||
this.rowsSkipped = source["rowsSkipped"];
|
||||
this.cancelled = source["cancelled"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export namespace syncjob {
|
||||
|
||||
export class CDCSpec {
|
||||
adapter?: string;
|
||||
startPosition?: string;
|
||||
initialSnapshot?: boolean;
|
||||
slotName?: string;
|
||||
publicationName?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CDCSpec(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.adapter = source["adapter"];
|
||||
this.startPosition = source["startPosition"];
|
||||
this.initialSnapshot = source["initialSnapshot"];
|
||||
this.slotName = source["slotName"];
|
||||
this.publicationName = source["publicationName"];
|
||||
}
|
||||
}
|
||||
export class TransformSpec {
|
||||
kind?: string;
|
||||
argument?: number[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TransformSpec(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.kind = source["kind"];
|
||||
this.argument = source["argument"];
|
||||
}
|
||||
}
|
||||
export class ColumnMapping {
|
||||
source?: string;
|
||||
target: string;
|
||||
transform?: TransformSpec;
|
||||
defaultValue?: number[];
|
||||
required?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ColumnMapping(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.source = source["source"];
|
||||
this.target = source["target"];
|
||||
this.transform = this.convertValues(source["transform"], TransformSpec);
|
||||
this.defaultValue = source["defaultValue"];
|
||||
this.required = source["required"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class EndpointRef {
|
||||
connectionId: string;
|
||||
connectionType?: string;
|
||||
connectionName?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
fingerprint?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new EndpointRef(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connectionId = source["connectionId"];
|
||||
this.connectionType = source["connectionType"];
|
||||
this.connectionName = source["connectionName"];
|
||||
this.database = source["database"];
|
||||
this.schema = source["schema"];
|
||||
this.fingerprint = source["fingerprint"];
|
||||
}
|
||||
}
|
||||
export class ExecutionApproval {
|
||||
definitionHash: string;
|
||||
targetFingerprint: string;
|
||||
approvedAt: number;
|
||||
approvedByRuntime: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExecutionApproval(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.definitionHash = source["definitionHash"];
|
||||
this.targetFingerprint = source["targetFingerprint"];
|
||||
this.approvedAt = source["approvedAt"];
|
||||
this.approvedByRuntime = source["approvedByRuntime"];
|
||||
}
|
||||
}
|
||||
export class ExecutionOptions {
|
||||
content?: string;
|
||||
syncMode?: string;
|
||||
targetTableStrategy?: string;
|
||||
autoAddColumns?: boolean;
|
||||
createIndexes?: boolean;
|
||||
propagateDeletes?: boolean;
|
||||
batchSize?: number;
|
||||
errorPolicy?: string;
|
||||
maxRetries?: number;
|
||||
retryBackoffMillis?: number;
|
||||
captureErrorPayload?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExecutionOptions(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.content = source["content"];
|
||||
this.syncMode = source["syncMode"];
|
||||
this.targetTableStrategy = source["targetTableStrategy"];
|
||||
this.autoAddColumns = source["autoAddColumns"];
|
||||
this.createIndexes = source["createIndexes"];
|
||||
this.propagateDeletes = source["propagateDeletes"];
|
||||
this.batchSize = source["batchSize"];
|
||||
this.errorPolicy = source["errorPolicy"];
|
||||
this.maxRetries = source["maxRetries"];
|
||||
this.retryBackoffMillis = source["retryBackoffMillis"];
|
||||
this.captureErrorPayload = source["captureErrorPayload"];
|
||||
}
|
||||
}
|
||||
export class ScheduleSpec {
|
||||
kind: string;
|
||||
runAt?: number;
|
||||
intervalSeconds?: number;
|
||||
cronExpression?: string;
|
||||
timezone?: string;
|
||||
anchorAt?: number;
|
||||
misfirePolicy?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScheduleSpec(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.kind = source["kind"];
|
||||
this.runAt = source["runAt"];
|
||||
this.intervalSeconds = source["intervalSeconds"];
|
||||
this.cronExpression = source["cronExpression"];
|
||||
this.timezone = source["timezone"];
|
||||
this.anchorAt = source["anchorAt"];
|
||||
this.misfirePolicy = source["misfirePolicy"];
|
||||
}
|
||||
}
|
||||
export class WatermarkSpec {
|
||||
column: string;
|
||||
initialValue?: number[];
|
||||
tieBreakerColumns?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WatermarkSpec(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.column = source["column"];
|
||||
this.initialValue = source["initialValue"];
|
||||
this.tieBreakerColumns = source["tieBreakerColumns"];
|
||||
}
|
||||
}
|
||||
export class TableMapping {
|
||||
sourceSchema?: string;
|
||||
sourceTable: string;
|
||||
targetSchema?: string;
|
||||
targetTable: string;
|
||||
targetTableStrategy?: string;
|
||||
filter?: string;
|
||||
keyColumns?: string[];
|
||||
columns?: ColumnMapping[];
|
||||
watermark?: WatermarkSpec;
|
||||
enabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TableMapping(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourceSchema = source["sourceSchema"];
|
||||
this.sourceTable = source["sourceTable"];
|
||||
this.targetSchema = source["targetSchema"];
|
||||
this.targetTable = source["targetTable"];
|
||||
this.targetTableStrategy = source["targetTableStrategy"];
|
||||
this.filter = source["filter"];
|
||||
this.keyColumns = source["keyColumns"];
|
||||
this.columns = this.convertValues(source["columns"], ColumnMapping);
|
||||
this.watermark = this.convertValues(source["watermark"], WatermarkSpec);
|
||||
this.enabled = source["enabled"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class JobDefinition {
|
||||
version: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
lifecycle: string;
|
||||
enabled: boolean;
|
||||
kind: string;
|
||||
incrementalMode: string;
|
||||
source: EndpointRef;
|
||||
target: EndpointRef;
|
||||
sourceQuery?: string;
|
||||
mappings: TableMapping[];
|
||||
options: ExecutionOptions;
|
||||
schedule: ScheduleSpec;
|
||||
cdc?: CDCSpec;
|
||||
approval?: ExecutionApproval;
|
||||
concurrencyPolicy?: string;
|
||||
resumePolicy?: string;
|
||||
revision: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
nextRunAt?: number;
|
||||
lastScheduledAt?: number;
|
||||
archivedAt?: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new JobDefinition(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.version = source["version"];
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.description = source["description"];
|
||||
this.lifecycle = source["lifecycle"];
|
||||
this.enabled = source["enabled"];
|
||||
this.kind = source["kind"];
|
||||
this.incrementalMode = source["incrementalMode"];
|
||||
this.source = this.convertValues(source["source"], EndpointRef);
|
||||
this.target = this.convertValues(source["target"], EndpointRef);
|
||||
this.sourceQuery = source["sourceQuery"];
|
||||
this.mappings = this.convertValues(source["mappings"], TableMapping);
|
||||
this.options = this.convertValues(source["options"], ExecutionOptions);
|
||||
this.schedule = this.convertValues(source["schedule"], ScheduleSpec);
|
||||
this.cdc = this.convertValues(source["cdc"], CDCSpec);
|
||||
this.approval = this.convertValues(source["approval"], ExecutionApproval);
|
||||
this.concurrencyPolicy = source["concurrencyPolicy"];
|
||||
this.resumePolicy = source["resumePolicy"];
|
||||
this.revision = source["revision"];
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
this.nextRunAt = source["nextRunAt"];
|
||||
this.lastScheduledAt = source["lastScheduledAt"];
|
||||
this.archivedAt = source["archivedAt"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user