-
Notifications
You must be signed in to change notification settings - Fork 47
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> = {}; | ||
|
||
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(), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Replace The use of For type GoogleCredentials = {
client_email: string;
private_key: string;
[key: string]: string;
}; For type LoginField = {
type: 'text' | 'password';
name: string;
value: string;
};
type LoginFields = {
[key: string]: LoginField;
}; Also applies to: 71-71 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ✏️ Learnings added
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exactly, this is the migration:
You can do it without all the previous There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. |
||
} |
There was a problem hiding this comment.
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 interfaceinterface VersionedConfig {version: number}
, but I couldn't get anything to work. If you can think of a way to avoidany
here please let me know 🙂