Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*
* Side-by-side data migrate: key-based insert/update/delete onto a destination
* grid (≤500 ops). Larger sets toast with Server Beam instructions.
* grid (≤ DATA_MIGRATE_ROW_CAP ops, 2,000). Larger sets toast with Server Beam instructions.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
compareKeyAlignedGrids,
type AlignRowOp,
} from '@/features/sql-editor/lib/resultKeyAlign';
import { migrateGridsAreComplete } from '@/features/sql-editor/lib/resultRowDiff';
import { DATA_MIGRATE_ROW_CAP, migrateGridsAreComplete } from '@/features/sql-editor/lib/resultRowDiff';
import {
COMPARE_PAGE_SIZES,
COMPARE_WINDOW_ROWS,
Expand Down Expand Up @@ -1754,7 +1754,7 @@ const SideBySideStatementSection: React.FC<{
<span className="text-sky-400/90">Sync</span> column (on by default for differing rows).{' '}
<span className="text-sky-400/90">Sync scroll</span> keeps all grids aligned;{' '}
<span className="text-sky-400/90">CSV all</span> downloads every grid in one file. Migrate
needs Add / Edit / Delete checked (enabled when diffs exist) plus Sync rows. Cap: 500 ops —
needs Add / Edit / Delete checked (enabled when diffs exist) plus Sync rows. Cap: {DATA_MIGRATE_ROW_CAP.toLocaleString()} ops —
larger sets use Server Beam.
</p>
) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,15 @@ describe('migrateKeysSafeForMutatingOps', () => {
});

describe('selectMigrateOps', () => {
it('respects checkboxes and caps at 500', () => {
const inserts = Array.from({ length: 300 }, (_, i) => ({
it('respects checkboxes and caps at the shared limit (2,000)', () => {
const inserts = Array.from({ length: 1200 }, (_, i) => ({
op: 'insert' as const,
keyLabel: `id=${i}`,
sourceRow: [i],
}));
const updates = Array.from({ length: 300 }, (_, i) => ({
const updates = Array.from({ length: 900 }, (_, i) => ({
op: 'update' as const,
keyLabel: `id=${i + 1000}`,
keyLabel: `id=${i + 5000}`,
sourceRow: [i],
destRow: [i],
}));
Expand All @@ -225,15 +225,16 @@ describe('selectMigrateOps', () => {
deletes: [],
skippedNullKeys: 0,
duplicateKeys: 0,
totalOps: 600,
totalOps: 2100,
unresolved: 0,
};
const selected = selectMigrateOps(
classification,
{ insert: true, update: true, delete: false },
DATA_MIGRATE_ROW_CAP
);
expect(selected.uncappedCount).toBe(600);
expect(DATA_MIGRATE_ROW_CAP).toBe(2000);
expect(selected.uncappedCount).toBe(2100);
expect(selected.truncated).toBe(true);
expect(selected.ops).toHaveLength(DATA_MIGRATE_ROW_CAP);
expect(selected.ops.every((o) => o.op === 'insert' || o.op === 'update')).toBe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

import { resultValuesEqual, type ResultGridLike } from './resultDataDiff';
import { normalizeResultKey } from '@/features/sql-editor/lib/resultValueKey';
import { DATA_MIGRATE_MAX_OPS } from '@foxschema/shared';
import type { PeekKeyColumn } from './rowDml';

export const DATA_MIGRATE_ROW_CAP = 500;
/** The route refuses more than this (`DATA_MIGRATE_MAX_OPS`, shared with the server). */
export const DATA_MIGRATE_ROW_CAP = DATA_MIGRATE_MAX_OPS;

export type RowDiffOp = 'insert' | 'update' | 'delete';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fakeReply, routeHandler } from '../../platform/http/route-doubles';
import { createDataMigrateRoutes } from './data-migrate.routes';
import { executeDataMigrateOps } from './data-migrate-execute';
import { DATA_MIGRATE_MAX_OPS } from '@foxschema/shared';

vi.mock('./data-migrate-execute', () => ({ executeDataMigrateOps: vi.fn(async () => ({ results: [] })) }));

Expand All @@ -37,6 +38,31 @@ describe('POST /data-migrate/execute', () => {
expect(executeDataMigrateOps).not.toHaveBeenCalled();
});

it('takes up to DATA_MIGRATE_MAX_OPS (2,000) ops, and refuses one more', async () => {
const handler = routeHandler(
createDataMigrateRoutes({
resolveRef: vi.fn().mockResolvedValue({ dialect: 'sqlite', option: {}, schema: '' }),
dataMigrateHistory: {} as never,
}),
'POST',
'/data-migrate/execute'
);
const ops = (n: number) =>
Array.from({ length: n }, (_, i) => ({ op: 'insert', key: `id=${i}`, sql: `INSERT INTO t (id) VALUES (${i})` }));
const call = async (n: number) => {
const { reply, sent } = fakeReply();
await handler({ body: { connectionId: 'c1', ops: ops(n) }, userId: 'u1', appRole: 'admin' } as never, reply as never);
return sent;
};
expect(DATA_MIGRATE_MAX_OPS).toBe(2000);
const over = await call(2001);
expect(over.status).toBe(400);
expect(over.body.error).toMatch(/At most 2000 ops/);
expect(executeDataMigrateOps).not.toHaveBeenCalled();
await call(2000);
expect(executeDataMigrateOps).toHaveBeenCalledOnce();
});

it('counts the statements inside a code fence', async () => {
const handler = routeHandler(
createDataMigrateRoutes({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { requirePermissions, denyUnless } from '../../platform/authorization/rba
import type { AuthedRequest } from '../../platform/http/types';
import type { ConnectionRef } from '../../platform/connections/resolve';
import { rateLimit } from '../../platform/guards/rate-limit';
import { DATA_MIGRATE_MAX_OPS } from '@foxschema/shared';
import { MAX_STATEMENT_LENGTH } from '../sql-editor';
import type { Permission } from '@foxschema/shared';
import { CATEGORY_PERMISSION, DATAGRID_ACTION_PERMISSION } from '@foxschema/shared';
Expand Down Expand Up @@ -52,8 +53,8 @@ export function createDataMigrateRoutes(deps: DataMigrateRouteDeps): Router {
sendError(res, 'invalid_input', 'ops[] is required.');
return;
}
if (body.ops.length > 500) {
sendError(res, 'invalid_input', 'At most 500 ops per data migrate.');
if (body.ops.length > DATA_MIGRATE_MAX_OPS) {
sendError(res, 'invalid_input', `At most ${DATA_MIGRATE_MAX_OPS} ops per data migrate.`);
return;
}
// The connection first: each op is classified as its database reads it.
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/data-migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* The most row operations one Data migrate sends. The browser caps what it
* offers and the route refuses more, from this one number: the two used to
* hold their own copies (500 each), and a change to one would have left the
* screen offering what the server refused.
*
* It matches the compare window (2,000 rows per side), so every difference a
* compare run finds can be applied from the screen that found it.
*/
export const DATA_MIGRATE_MAX_OPS = 2000;
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './lokee-wire';
export * from './nav';
export * from './password-policy';
export * from './migration-status';
export * from './data-migrate';
Loading