Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New: Add config migrations mechanism #648

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@
"react-dom": "^18.3.1",
"svelte": "^4.2.19",
"web-vitals": "^4.2.3",
"ynab": "^1.19.0"
"ynab": "^1.19.0",
"zod": "^3.24.2"
},
"resolutions": {
"underscore": "1.12.1",
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/backend/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
Spreadsheet,
} from './export/outputVendors/googleSheets/googleSheetsInternalAPI';
export interface Config {
version: number;
outputVendors: {
[OutputVendorName.GOOGLE_SHEETS]?: GoogleSheetsConfig;
[OutputVendorName.YNAB]?: YnabConfig;
Expand Down
3 changes: 2 additions & 1 deletion packages/main/src/backend/configManager/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { configFilePath } from '@/app-globals';
import { type Config } from '@/backend/commonTypes';
import { decrypt, encrypt } from '@/backend/configManager/encryption/crypto';
import { existsSync, promises as fs } from 'fs';
import { migrateConfig } from './configMigration/configMigrator';
import configExample from './defaultConfig';
import logger from '/@/logging/logger';

Expand All @@ -19,7 +20,7 @@ export async function getConfig(configPath: string = configFilePath): Promise<Co
logger.log('Empty config file found, returning default config');
return configExample;
}
return config;
return migrateConfig(config);
} catch (e) {
logger.error('Failed to parse config file, returning default config', e);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type Config } from '../../commonTypes';
import { isOriginalConfig } from './versions/original';
import { migrateOriginalToV1, v1ConfigSchema } from './versions/v1';

const latestConfigSchema = v1ConfigSchema;

// migrations[n] should be a function that converts version n to version n+1
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const migrations: Record<number, (config: any) => any> = {};
Comment on lines +8 to +10
Copy link
Contributor Author

@whatuserever whatuserever Feb 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since migrations is gonna hold all migration functions, and each migration function has a different signature (v1 => v2, v2 => v3, etc...), I'm not sure if it's possible to type this correctly :/

I tried experimenting with z.discriminatedUnion('version', [...]), or defining a shared interface interface VersionedConfig {version: number}, but I couldn't get anything to work. If you can think of a way to avoid any here please let me know 🙂


export function migrateConfig(config: unknown): Config {
let currentConfig = config;
// original config does not have version key and must be handled separately
if (isOriginalConfig(config)) {
currentConfig = migrateOriginalToV1(config);
}
let currentVersion = getConfigVersion(currentConfig);

while (migrations[currentVersion]) {
currentConfig = migrations[currentVersion](currentConfig);
currentVersion = getConfigVersion(currentConfig);
}

return latestConfigSchema.parse(currentConfig) as Config;
}

function getConfigVersion(config: unknown): keyof typeof migrations {
const versionSchema = z.object({ version: z.number().int().positive() });
return versionSchema.parse(config).version;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { z } from 'zod';

export const outputVendorNameSchema = z.enum(['ynab', 'googleSheets', 'json', 'csv']);

export const companyTypeSchema = z.enum([
'hapoalim',
'hapoalimBeOnline',
'beinleumi',
'union',
'amex',
'isracard',
'visaCal',
'max',
'leumiCard',
'otsarHahayal',
'discount',
'mercantile',
'mizrahi',
'leumi',
'massad',
'yahav',
'behatsdaa',
'beyahadBishvilha',
'oneZero',
'pagi',
]);

export const googleSheetsConfigSchema = z.object({
active: z.boolean(),
options: z.object({
credentials: z.any(),
Copy link

@coderabbitai coderabbitai bot Feb 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace any types with more specific types.

The use of any in credentials and loginFields bypasses TypeScript's type checking. Consider defining specific types or interfaces for these fields to maintain type safety.

For credentials, consider using the Google OAuth2 credentials type:

type GoogleCredentials = {
  client_email: string;
  private_key: string;
  [key: string]: string;
};

For loginFields, consider defining a union type of possible login field configurations:

type LoginField = {
  type: 'text' | 'password';
  name: string;
  value: string;
};

type LoginFields = {
  [key: string]: LoginField;
};

Also applies to: 71-71

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

credentials and loginFields are coming from 3rd parties (Google, israeli-bank-scrapers). I didn't want to create a schema for them as they're outside of our control.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@whatuserever Understood, that's a valid approach for handling third-party data structures that are outside of our control. I'll retract my suggestion about replacing these specific any types.


✏️ Learnings added
Learnt from: whatuserever
PR: brafdlog/caspion#648
File: packages/main/src/backend/configManager/configMigration/versions/original.ts:31-31
Timestamp: 2025-02-16T12:10:12.122Z
Learning: In the Caspion project, `any` type is intentionally used for third-party data structures (like `credentials` from Google and `loginFields` from israeli-bank-scrapers) that are outside of our control to avoid maintenance overhead and potential breakage when third-party structures change.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

spreadsheetId: z.string(),
}),
});

export const ynabConfigSchema = z.object({
active: z.boolean(),
options: z.object({
accessToken: z.string(),
accountNumbersToYnabAccountIds: z.record(z.string(), z.string()),
budgetId: z.string(),
maxPayeeNameLength: z.number().optional(),
}),
});

export const jsonConfigSchema = z.object({
active: z.boolean(),
options: z.object({
filePath: z.string(),
}),
});

export const csvConfigSchema = z.object({
active: z.boolean(),
options: z.object({
filePath: z.string(),
}),
});

export const outputVendorsSchema = z.object({
[outputVendorNameSchema.Values.googleSheets]: googleSheetsConfigSchema.optional(),
[outputVendorNameSchema.Values.ynab]: ynabConfigSchema.optional(),
[outputVendorNameSchema.Values.json]: jsonConfigSchema.optional(),
[outputVendorNameSchema.Values.csv]: csvConfigSchema.optional(),
});

export const accountToScrapeConfigSchema = z.object({
id: z.string(),
key: companyTypeSchema,
name: z.string(),
loginFields: z.any(),
active: z.boolean().optional(),
});

export const scrapingSchema = z.object({
numDaysBack: z.number(),
showBrowser: z.boolean(),
accountsToScrape: z.array(accountToScrapeConfigSchema),
chromiumPath: z.string().optional(),
maxConcurrency: z.number().optional(),
timeout: z.number(),
periodicScrapingIntervalHours: z.number().optional(),
});

export const originalConfigSchema = z.object({
outputVendors: outputVendorsSchema,
scraping: scrapingSchema,
useReactUI: z.boolean().optional(),
});

export function isOriginalConfig(obj: unknown): obj is z.infer<typeof originalConfigSchema> {
const parseResult = originalConfigSchema.strict().safeParse(obj);
return parseResult.success;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod';
import { originalConfigSchema } from './original';

export const v1ConfigSchema = originalConfigSchema.extend({ version: z.literal(1) });

export function migrateOriginalToV1(v1Config: z.infer<typeof originalConfigSchema>): z.infer<typeof v1ConfigSchema> {
return {
...v1Config,
version: 1,
};
Comment on lines +7 to +10
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, this is the migration:

const migrated = {
  ...originalConfig,
  version: 1
}

You can do it without all the previous zod preparations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preperation is needed to create a snapshot of the config interface that is decoupled from the Config type used in the "live" code. Otherwise, how can you write a function that accepts version n and returns version n+1, while the live code is at the latest version m?

Maybe looking at what I did for v2 will help clarify that? You can check it out here (I didn't update #647 yet as I'm waiting for this PR to get approved).

We can do this preperation by copying the types instead of using Zod, but we still need it. And in my opinion Zod is a better fit for this task as it lets you safely parse the input file, allowing you to verify the version before applying any migrations.

}
1 change: 1 addition & 0 deletions packages/main/src/backend/configManager/defaultConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Config } from '../commonTypes';

const DEFAULT_CONFIG: Config = {
version: 1,
scraping: {
numDaysBack: 40,
showBrowser: false,
Expand Down
1 change: 1 addition & 0 deletions packages/preload/src/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum OutputVendorName {
}

export interface Config {
version: number;
outputVendors: {
[OutputVendorName.GOOGLE_SHEETS]?: GoogleSheetsConfig;
[OutputVendorName.YNAB]?: YnabConfig;
Expand Down
1 change: 1 addition & 0 deletions packages/renderer/src/store/Store.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ describe('Store', () => {
});

export const dummyConfig: Config = {
version: 1,
scraping: {
numDaysBack: 40,
showBrowser: false,
Expand Down
1 change: 1 addition & 0 deletions packages/renderer/src/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export enum OutputVendorName {
}

export interface Config {
version: number;
outputVendors: {
[OutputVendorName.GOOGLE_SHEETS]?: GoogleSheetsConfig;
[OutputVendorName.YNAB]?: YnabConfig;
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6493,3 +6493,8 @@ zod@3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==

zod@^3.24.2:
version "3.24.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3"
integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==