diff --git a/tools/revert-update/index.ts b/tools/revert-update/index.ts new file mode 100644 index 0000000000..f9052a7bee --- /dev/null +++ b/tools/revert-update/index.ts @@ -0,0 +1,180 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +import { + applyUpdate, + Doc, + encodeStateAsUpdate, + encodeStateVector, + UndoManager, +} from 'yjs'; + +type InputFormat = 'file' | 'base64' | 'hex'; +type OutputFormat = 'bin' | 'base64' | 'hex'; + +const HELP_TEXT = ` +Generate a revert update from two Yjs snapshot binaries. + +Usage: + r ./tools/revert-update/index.ts --from --to [options] + +Options: + --from Newer snapshot input (path/base64/hex) + --to Older snapshot input (path/base64/hex) + --from-format file|base64|hex (default: file) + --to-format file|base64|hex (default: file) + --out Output path (default: stdout) + --out-format bin|base64|hex (default: base64 if stdout, bin if file) + -h, --help Show help + +Examples: + r ./tools/revert-update/index.ts --from ./from.bin --to ./to.bin --out ./revert.bin + r ./tools/revert-update/index.ts --from "$FROM" --from-format base64 --to "$TO" --to-format base64 +`; + +function generateRevertUpdate( + fromNewerBin: Uint8Array, + toOlderBin: Uint8Array +): Uint8Array { + const newerDoc = new Doc(); + applyUpdate(newerDoc, fromNewerBin); + const olderDoc = new Doc(); + applyUpdate(olderDoc, toOlderBin); + + const newerState = encodeStateVector(newerDoc); + const olderState = encodeStateVector(olderDoc); + + const diff = encodeStateAsUpdate(newerDoc, olderState); + const undoManager = new UndoManager(Array.from(olderDoc.share.values())); + + applyUpdate(olderDoc, diff); + undoManager.undo(); + + return encodeStateAsUpdate(olderDoc, newerState); +} + +function fail(message: string): never { + console.error(message); + console.error(HELP_TEXT.trim()); + process.exit(1); +} + +function parseInputFormat(value: string, flag: string): InputFormat { + switch (value) { + case 'file': + case 'base64': + case 'hex': + return value; + default: + fail(`Unknown ${flag} value: ${value}`); + } +} + +function parseOutputFormat(value: string, flag: string): OutputFormat { + switch (value) { + case 'bin': + case 'base64': + case 'hex': + return value; + default: + fail(`Unknown ${flag} value: ${value}`); + } +} + +function parseArgs(argv: string[]) { + const args = new Map(); + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { + console.log(HELP_TEXT.trim()); + process.exit(0); + } + if (!arg.startsWith('--')) { + fail(`Unknown argument: ${arg}`); + } + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for ${arg}`); + } + args.set(arg, value); + i += 1; + } + + const from = args.get('--from'); + const to = args.get('--to'); + if (!from || !to) { + fail('Both --from and --to are required.'); + } + + const fromFormat = parseInputFormat( + (args.get('--from-format') ?? 'file').toLowerCase(), + '--from-format' + ); + const toFormat = parseInputFormat( + (args.get('--to-format') ?? 'file').toLowerCase(), + '--to-format' + ); + + const outPath = args.get('--out'); + const defaultOutFormat = outPath ? 'bin' : 'base64'; + const outFormat = parseOutputFormat( + (args.get('--out-format') ?? defaultOutFormat).toLowerCase(), + '--out-format' + ); + + return { + from, + to, + fromFormat, + toFormat, + outPath, + outFormat, + }; +} + +function readInput(value: string, format: InputFormat): Uint8Array { + try { + if (format === 'file') { + return new Uint8Array(readFileSync(value)); + } + const trimmed = value.trim(); + const decoded = Buffer.from(trimmed, format === 'hex' ? 'hex' : 'base64'); + return new Uint8Array(decoded); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + fail(`Failed to read ${format} input: ${details}`); + } +} + +function writeOutput( + update: Uint8Array, + outPath: string | undefined, + format: OutputFormat +) { + if (outPath) { + if (format === 'bin') { + writeFileSync(outPath, update); + return; + } + const encoded = Buffer.from(update).toString(format); + writeFileSync(outPath, encoded); + return; + } + + if (format === 'bin') { + process.stdout.write(Buffer.from(update)); + return; + } + const encoded = Buffer.from(update).toString(format); + process.stdout.write(`${encoded}\n`); +} + +const { from, to, fromFormat, toFormat, outPath, outFormat } = parseArgs( + process.argv.slice(2) +); + +const fromBin = readInput(from, fromFormat); +const toBin = readInput(to, toFormat); + +const revertUpdate = generateRevertUpdate(fromBin, toBin); + +writeOutput(revertUpdate, outPath, outFormat); diff --git a/tools/revert-update/package.json b/tools/revert-update/package.json new file mode 100644 index 0000000000..c00a9cc8bd --- /dev/null +++ b/tools/revert-update/package.json @@ -0,0 +1,19 @@ +{ + "name": "@affine/revert-update", + "version": "0.26.0", + "private": true, + "type": "module", + "description": "Generate a revert update from two Yjs snapshot binaries", + "main": "index.ts", + "scripts": { + "generate": "r ./index.ts" + }, + "dependencies": { + "@affine-tools/cli": "workspace:*", + "typescript": "^5.7.2", + "yjs": "^13.6.27" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } +} diff --git a/tools/revert-update/tsconfig.json b/tools/revert-update/tsconfig.json new file mode 100644 index 0000000000..98b83b35cc --- /dev/null +++ b/tools/revert-update/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist" + }, + "files": ["index.ts"], + "references": [{ "path": "../cli" }] +} diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 3b5c6eb94d..f7f560bd90 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1476,6 +1476,11 @@ export const PackageList = [ name: '@affine/playstore-auto-bump', workspaceDependencies: ['tools/cli', 'tools/utils'], }, + { + location: 'tools/revert-update', + name: '@affine/revert-update', + workspaceDependencies: ['tools/cli'], + }, { location: 'tools/utils', name: '@affine-tools/utils', @@ -1598,4 +1603,5 @@ export type PackageName = | '@affine/commitlint-config' | '@affine/copilot-result' | '@affine/playstore-auto-bump' + | '@affine/revert-update' | '@affine-tools/utils'; diff --git a/tsconfig.json b/tsconfig.json index 1a43f6aa29..ec6635d2f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -157,6 +157,7 @@ { "path": "./tests/kit" }, { "path": "./tools/cli" }, { "path": "./tools/playstore-auto-bump" }, + { "path": "./tools/revert-update" }, { "path": "./tools/utils" } ] }