diff --git a/.vscode/settings.json b/.vscode/settings.json index 9d82aa1..ed42e20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,18 @@ { "i18n-ally.localesPaths": ["src/renderer/locales"], "vitest.disableWorkspaceWarning": true, - "i18n-ally.keystyle": "nested" + "i18n-ally.keystyle": "nested", + "cSpell.words": [ + "bridgecmdr", + "extron", + "fgpa", + "pinia", + "radash", + "shinybow", + "sindresorhus", + "sixxgate", + "snes", + "vuelidate", + "vuetify" + ] } diff --git a/package.json b/package.json index 9235e8d..9769563 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "execa": "^9.5.1", "husky": "^9.1.6", "ini": "^5.0.0", - "js-base64": "^3.7.7", "levelup": "^5.1.1", "mime": "^4.0.4", "npm-check-updates": "^17.1.10", diff --git a/src/core/base64.ts b/src/core/base64.ts new file mode 100644 index 0000000..72cdd86 --- /dev/null +++ b/src/core/base64.ts @@ -0,0 +1,15 @@ +/** Converts a byte array into a Base64 string. */ +export function toBase64(data: Uint8Array) { + return btoa(String.fromCodePoint(...data)) +} + +function* codePointsOf(data: string) { + for (let i = 0, cp = data.codePointAt(i); cp != null; ++i, cp = data.codePointAt(i)) { + yield cp + } +} + +/** Coverts a Base64 string into a byte array. */ +export function fromBase64(data: string) { + return new Uint8Array([...codePointsOf(atob(data))]) +} diff --git a/src/core/rpc/transformer.ts b/src/core/rpc/transformer.ts index 4140ec4..8a096e2 100644 --- a/src/core/rpc/transformer.ts +++ b/src/core/rpc/transformer.ts @@ -1,36 +1,78 @@ -import { Base64 } from 'js-base64' import { SuperJSON } from 'superjson' import { Attachment } from '../attachments' import { raiseError } from '../error-handling' +import { fromBase64, toBase64 } from '@/base64' -export default function useSuperJson() { - SuperJSON.registerCustom( +function useSuperJsonCommon() { + const superJson = new SuperJSON() + + // HACK: tRPC and SuperJSON won't serialize functions; but + // doesn't filter them out, so the IPC throws an error + // if they are passed. They are also not passable via + // HTTP JSON serialization. This will ensure any + // types with functions won't be usable with + // routes, as functions will trigger an + // Error with this transformation. + superJson.registerCustom( + { + isApplicable: (v): v is () => undefined => typeof v === 'function', + serialize: (_: () => undefined) => raiseError(() => new TypeError('Functions may not be serialized')), + deserialize: (_: never) => raiseError(() => new TypeError('Functions may not be serialized')) + }, + 'Function' + ) + + return superJson +} + +/** + * Sets up SuperJSON for use over Electron IPC. + * + * This will attempt to translate data into a form compatible with the limited + * structuralClone ability of Electron's IPC channels. This means most + * builtin objects and a very limited set of host objects may be + * passed directly through without translation. + */ +export function useIpcJson() { + const superJson = useSuperJsonCommon() + superJson.registerCustom( { isApplicable: (v) => v instanceof Attachment, serialize: (attachment) => ({ name: attachment.name, type: attachment.type, - data: Base64.fromUint8Array(attachment) + data: new Uint8Array(attachment.buffer) as never }), - deserialize: (attachment) => - new Attachment(attachment.name, attachment.type, Base64.toUint8Array(attachment.data)) + deserialize: (attachment) => new Attachment(attachment.name, attachment.type, attachment.data) }, 'Attachment' ) - // HACK: tRPC won't serialize functions; but doesn't filter - // them out, so IPC throws an error if they are passed. - // This can also ensure any types with functions - // won't be returned as seen in the return - // type information for the router. - SuperJSON.registerCustom( + return superJson +} + +/** + * Sets up SuperJSON for use over HTTP. + * + * This will attempt to translate data into a form compatible with normal JSON. + * This means only data allowed in regular JSON may be passed, other types + * of data must be translated into the best possible representation. + * Binary data will need to be Base64 encoded. + */ +export function useWebJson() { + const superJson = useSuperJsonCommon() + superJson.registerCustom( { - isApplicable: (v): v is () => undefined => typeof v === 'function', - serialize: (_: () => undefined) => raiseError(() => new TypeError('Functions may not be serialized')), - deserialize: (_: never) => raiseError(() => new TypeError('Functions may not be serialized')) + isApplicable: (v) => v instanceof Attachment, + serialize: (attachment) => ({ + name: attachment.name, + type: attachment.type, + data: toBase64(attachment) + }), + deserialize: (attachment) => new Attachment(attachment.name, attachment.type, fromBase64(attachment.data)) }, - 'Function' + 'Attachment' ) - return SuperJSON + return superJson } diff --git a/src/main/dao/devices.ts b/src/main/dao/devices.ts new file mode 100644 index 0000000..3b4417a --- /dev/null +++ b/src/main/dao/devices.ts @@ -0,0 +1,47 @@ +import { memo } from 'radash' +import { z } from 'zod' +import { + Database, + DocumentId, + inferDocumentOf, + inferNewDocumentOf, + inferUpdatesOf, + inferUpsertOf +} from '../services/database' +import useTiesDatabase from './ties' +import type { RevisionId } from '../services/database' + +export const DeviceModel = z.object({ + driverId: DocumentId, + title: z.string().min(1), + path: z.string().min(1) +}) + +const useDevicesDatabase = memo( + () => + new (class extends Database.of('devices', DeviceModel) { + readonly #ties = useTiesDatabase() + + override async remove(id: DocumentId, rev?: RevisionId) { + await super.remove(id, rev) + + const related = await this.#ties.forDevice(id) + await Promise.all( + related.map(async ({ _id, _rev }) => { + await this.#ties.remove(_id, _rev) + }) + ) + } + })() +) + +export type Device = inferDocumentOf +export const Device = inferDocumentOf(DeviceModel) +export type NewDevice = inferNewDocumentOf +export const NewDevice = inferNewDocumentOf(DeviceModel) +export type DeviceUpdate = inferUpdatesOf +export const DeviceUpdate = inferUpdatesOf(DeviceModel) +export type DeviceUpsert = inferUpsertOf +export const DeviceUpsert = inferUpsertOf(DeviceModel) + +export default useDevicesDatabase diff --git a/src/main/dao/sources.ts b/src/main/dao/sources.ts index 0366897..42b59d0 100644 --- a/src/main/dao/sources.ts +++ b/src/main/dao/sources.ts @@ -1,6 +1,6 @@ import { memo } from 'radash' import { z } from 'zod' -import { Database, inferDocumentOf, inferNewDocumentOf, inferUpdatesOf } from '../services/database' +import { Database, inferDocumentOf, inferNewDocumentOf, inferUpdatesOf, inferUpsertOf } from '../services/database' import useTiesDatabase from './ties' import type { DocumentId, RevisionId } from '../services/database' @@ -33,5 +33,7 @@ export type NewSource = inferNewDocumentOf export const NewSource = inferNewDocumentOf(SourceModel) export type SourceUpdate = inferUpdatesOf export const SourceUpdate = inferUpdatesOf(SourceModel) +export type SourceUpsert = inferUpsertOf +export const SourceUpsert = inferUpsertOf(SourceModel) export default useSourcesDatabase diff --git a/src/main/dao/switches.ts b/src/main/dao/switches.ts deleted file mode 100644 index 52a84a9..0000000 --- a/src/main/dao/switches.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import { Database, DocumentId, inferDocumentOf, inferNewDocumentOf, inferUpdatesOf } from '../services/database' -import useTiesDatabase from './ties' -import type { RevisionId } from '../services/database' - -export const SwitchModel = z.object({ - driverId: DocumentId, - title: z.string().min(1), - path: z.string().min(1) -}) - -const useSwitchesDatabase = memo( - () => - new (class extends Database.of('switches', SwitchModel) { - readonly #ties = useTiesDatabase() - - override async remove(id: DocumentId, rev?: RevisionId) { - await super.remove(id, rev) - - const related = await this.#ties.forSwitch(id) - await Promise.all( - related.map(async ({ _id, _rev }) => { - await this.#ties.remove(_id, _rev) - }) - ) - } - })() -) - -export type Switch = inferDocumentOf -export const Switch = inferDocumentOf(SwitchModel) -export type NewSwitch = inferNewDocumentOf -export const NewSwitch = inferNewDocumentOf(SwitchModel) -export type SwitchUpdate = inferUpdatesOf -export const SwitchUpdate = inferUpdatesOf(SwitchModel) - -export default useSwitchesDatabase diff --git a/src/main/dao/ties.ts b/src/main/dao/ties.ts index 6d48f20..2859dbe 100644 --- a/src/main/dao/ties.ts +++ b/src/main/dao/ties.ts @@ -1,10 +1,17 @@ import { map, memo } from 'radash' import { z } from 'zod' -import { Database, DocumentId, inferDocumentOf, inferNewDocumentOf, inferUpdatesOf } from '../services/database' +import { + Database, + DocumentId, + inferDocumentOf, + inferNewDocumentOf, + inferUpdatesOf, + inferUpsertOf +} from '../services/database' export const TieModel = z.object({ sourceId: DocumentId, - switchId: DocumentId, + deviceId: DocumentId, inputChannel: z.number().int().nonnegative(), outputChannels: z.object({ video: z.number().int().nonnegative().optional(), @@ -12,16 +19,16 @@ export const TieModel = z.object({ }) }) -const indices = { sourceId: ['sourceId'], switchId: ['switchId'] } +const indices = { sourceId: ['sourceId'], deviceId: ['deviceId'] } const useTiesDatabase = memo( () => new (class extends Database.of('ties', TieModel, indices) { - async forSwitch(switchId: DocumentId) { + async forDevice(deviceId: DocumentId) { return await this.run( async (db) => await db - .find({ selector: { switchId } }) + .find({ selector: { deviceId } }) .then(async (r) => await map(r.docs, async (d) => await this.prepare(d))) ) } @@ -43,5 +50,7 @@ export type NewTie = inferNewDocumentOf export const NewTie = inferNewDocumentOf(TieModel) export type TieUpdate = inferUpdatesOf export const TieUpdate = inferUpdatesOf(TieModel) +export type TieUpsert = inferUpsertOf +export const TieUpsert = inferUpsertOf(TieModel) export default useTiesDatabase diff --git a/src/main/main.ts b/src/main/main.ts index c0c35c4..1743be0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,6 +6,7 @@ import Logger from 'electron-log' import { sleep } from 'radash' import appIcon from '../../resources/icon.png?asset&asarUnpack' import { useAppRouter } from './routes/router' +import useMigrations from './services/migration' import { createIpcHandler } from './services/rpc/ipc' import { logError } from './utilities' import { toError } from '@/error-handling' @@ -13,7 +14,6 @@ import { toError } from '@/error-handling' // In this file you can include the rest of your app"s specific main process // code. You can also put them in separate files and require them here. -Logger.initialize({ preload: true, spyRendererConsole: true }) Logger.transports.console.format = '{h}:{i}:{s}.{ms} [{level}] › {text}' Logger.transports.file.level = 'debug' Logger.errorHandler.startCatching() @@ -53,6 +53,26 @@ async function createWindow() { const kWait = 2000 let lastError: unknown + window.webContents.on('console-message', (_, level, message) => { + switch (level) { + case 0: + Logger.verbose(message) + break + case 1: + Logger.info(message) + break + case 2: + Logger.warn(message) + break + case 3: + Logger.error(message) + break + default: + Logger.log(message) + break + } + }) + /* eslint-disable no-await-in-loop -- Retry loop must be serial. */ for (let tries = 3; tries > 0; --tries) { try { @@ -106,6 +126,11 @@ process.on('SIGTERM', () => { // this event occurs. await app.whenReady() +const migrate = useMigrations() +await migrate().catch((cause: unknown) => { + Logger.error(cause) +}) + // Set app user model id for windows electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') diff --git a/src/main/migrations/20241118211400-rename-switches.ts b/src/main/migrations/20241118211400-rename-switches.ts new file mode 100644 index 0000000..a76860e --- /dev/null +++ b/src/main/migrations/20241118211400-rename-switches.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { Database, DocumentId } from '../services/database' + +export async function migrate() { + const Model = z.object({ + driverId: DocumentId, + title: z.string().min(1), + path: z.string().min(1) + }) + + const switches = new Database('switches', Model) + const devices = new Database('devices', Model) + + for (const device of await switches.all()) { + // eslint-disable-next-line no-await-in-loop + await devices.upsert(device) + } + + await switches.destroy() + await devices.close() +} diff --git a/src/main/migrations/20241119202100-rename-switchId.ts b/src/main/migrations/20241119202100-rename-switchId.ts new file mode 100644 index 0000000..7bf0831 --- /dev/null +++ b/src/main/migrations/20241119202100-rename-switchId.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { Database, DocumentId } from '../services/database' + +export async function migrate() { + const Model = z.object({}).catchall(z.unknown()) + + const OldModel = z + .object({ + _id: DocumentId, + sourceId: DocumentId, + switchId: DocumentId, + inputChannel: z.number().int().nonnegative(), + outputChannels: z.object({ + video: z.number().int().nonnegative().optional(), + audio: z.number().int().nonnegative().optional() + }) + }) + .passthrough() + + const ties = new Database('ties', Model) + + for (const tie of await ties.all()) { + const { switchId, ...old } = OldModel.parse(tie) + + // eslint-disable-next-line no-await-in-loop + await ties.replace({ ...old, deviceId: switchId }) + } +} diff --git a/src/main/routes/data/devices.ts b/src/main/routes/data/devices.ts new file mode 100644 index 0000000..62bde72 --- /dev/null +++ b/src/main/routes/data/devices.ts @@ -0,0 +1,23 @@ +import useDevicesDatabase, { NewDevice, DeviceUpdate, DeviceUpsert } from '../../dao/devices' +import { DocumentId } from '../../services/database' +import { procedure, router } from '../../services/rpc/trpc' + +export default function useDevicesRouter() { + const devices = useDevicesDatabase() + return router({ + compact: procedure.mutation(async () => { + await devices.compact() + }), + all: procedure.query(async () => await devices.all()), + get: procedure.input(DocumentId).query(async ({ input }) => await devices.get(input)), + add: procedure.input(NewDevice).mutation(async ({ input }) => await devices.add(input)), + update: procedure.input(DeviceUpdate).mutation(async ({ input }) => await devices.update(input)), + upsert: procedure.input(DeviceUpsert).mutation(async ({ input }) => await devices.upsert(input)), + remove: procedure.input(DocumentId).mutation(async ({ input }) => { + await devices.remove(input) + }), + clear: procedure.mutation(async () => { + await devices.clear() + }) + }) +} diff --git a/src/main/routes/data/sources.ts b/src/main/routes/data/sources.ts index 956a079..1bf6c29 100644 --- a/src/main/routes/data/sources.ts +++ b/src/main/routes/data/sources.ts @@ -1,13 +1,12 @@ import { z } from 'zod' -import useSourcesDatabase, { NewSource, SourceUpdate } from '../../dao/sources' +import useSourcesDatabase, { NewSource, SourceUpdate, SourceUpsert } from '../../dao/sources' import { DocumentId } from '../../services/database' import { procedure, router } from '../../services/rpc/trpc' import { Attachment } from '@/attachments' -export type { Source, NewSource, SourceUpdate } from '../../dao/sources' - const InsertInputs = z.tuple([NewSource]).rest(z.instanceof(Attachment)) const UpdateInputs = z.tuple([SourceUpdate]).rest(z.instanceof(Attachment)) +const UpsertInputs = z.tuple([SourceUpsert]).rest(z.instanceof(Attachment)) export default function useSourcesRouter() { const sources = useSourcesDatabase() @@ -19,6 +18,7 @@ export default function useSourcesRouter() { get: procedure.input(DocumentId).query(async ({ input }) => await sources.get(input)), add: procedure.input(InsertInputs).mutation(async ({ input }) => await sources.add(...input)), update: procedure.input(UpdateInputs).mutation(async ({ input }) => await sources.update(...input)), + upsert: procedure.input(UpsertInputs).mutation(async ({ input }) => await sources.upsert(...input)), remove: procedure.input(DocumentId).mutation(async ({ input }) => { await sources.remove(input) }), diff --git a/src/main/routes/data/switches.ts b/src/main/routes/data/switches.ts deleted file mode 100644 index 9bbbb31..0000000 --- a/src/main/routes/data/switches.ts +++ /dev/null @@ -1,24 +0,0 @@ -import useSwitchesDatabase, { NewSwitch, SwitchUpdate } from '../../dao/switches' -import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/rpc/trpc' - -export { Switch, NewSwitch, SwitchUpdate } from '../../dao/switches' - -export default function useSourcesRouter() { - const switches = useSwitchesDatabase() - return router({ - compact: procedure.mutation(async () => { - await switches.compact() - }), - all: procedure.query(async () => await switches.all()), - get: procedure.input(DocumentId).query(async ({ input }) => await switches.get(input)), - add: procedure.input(NewSwitch).mutation(async ({ input }) => await switches.add(input)), - update: procedure.input(SwitchUpdate).mutation(async ({ input }) => await switches.update(input)), - remove: procedure.input(DocumentId).mutation(async ({ input }) => { - await switches.remove(input) - }), - clear: procedure.mutation(async () => { - await switches.clear() - }) - }) -} diff --git a/src/main/routes/data/ties.ts b/src/main/routes/data/ties.ts index 6532dbc..ef069b7 100644 --- a/src/main/routes/data/ties.ts +++ b/src/main/routes/data/ties.ts @@ -1,9 +1,7 @@ -import useTiesDatabase, { NewTie, TieUpdate } from '../../dao/ties' +import useTiesDatabase, { NewTie, TieUpdate, TieUpsert } from '../../dao/ties' import { DocumentId } from '../../services/database' import { procedure, router } from '../../services/rpc/trpc' -export type { Tie, NewTie, TieUpdate } from '../../dao/ties' - export default function useTiesRouter() { const ties = useTiesDatabase() return router({ @@ -14,13 +12,14 @@ export default function useTiesRouter() { get: procedure.input(DocumentId).query(async ({ input }) => await ties.get(input)), add: procedure.input(NewTie).mutation(async ({ input }) => await ties.add(input)), update: procedure.input(TieUpdate).mutation(async ({ input }) => await ties.update(input)), + upsert: procedure.input(TieUpsert).mutation(async ({ input }) => await ties.upsert(input)), remove: procedure.input(DocumentId).mutation(async ({ input }) => { await ties.remove(input) }), clear: procedure.mutation(async () => { await ties.clear() }), - forSwitch: procedure.input(DocumentId).query(async ({ input }) => await ties.forSwitch(input)), + forDevice: procedure.input(DocumentId).query(async ({ input }) => await ties.forDevice(input)), forSource: procedure.input(DocumentId).query(async ({ input }) => await ties.forSource(input)) }) } diff --git a/src/main/routes/drivers.ts b/src/main/routes/drivers.ts index 4d0c409..58f781e 100644 --- a/src/main/routes/drivers.ts +++ b/src/main/routes/drivers.ts @@ -12,7 +12,7 @@ const useDriversRouter = memo(function useDriversRoute() { const drivers = useDrivers() return router({ - all: procedure.query(drivers.allInfo), + all: procedure.query(async () => await drivers.allInfo()), get: procedure.input(z.string().uuid()).query(async ({ input }) => { await drivers.get(input) }), diff --git a/src/main/routes/migration.ts b/src/main/routes/migration.ts new file mode 100644 index 0000000..cbb0553 --- /dev/null +++ b/src/main/routes/migration.ts @@ -0,0 +1,15 @@ +import { memo } from 'radash' +import useMigrations from '../services/migration' +import { procedure, router } from '../services/rpc/trpc' + +const useMigrationRouter = memo(function useMigrationRouter() { + const migrate = useMigrations() + + return router({ + migrate: procedure.mutation(async () => { + await migrate() + }) + }) +}) + +export default useMigrationRouter diff --git a/src/main/routes/ports.ts b/src/main/routes/ports.ts index a9541e1..a37c4c5 100644 --- a/src/main/routes/ports.ts +++ b/src/main/routes/ports.ts @@ -7,7 +7,7 @@ const useSerialPortRouter = memo(function useSerialPortRouter() { const ports = useSerialPorts() return router({ - list: procedure.query(ports.listPorts), + list: procedure.query(async () => await ports.listPorts()), isPort: procedure.input(z.string()).query(async ({ input }) => await ports.isValidPort(input)) }) }) diff --git a/src/main/routes/router.ts b/src/main/routes/router.ts index 28ac076..4493b02 100644 --- a/src/main/routes/router.ts +++ b/src/main/routes/router.ts @@ -2,11 +2,12 @@ import { memo } from 'radash' import useAppInfo from '../services/app' import { procedure, router } from '../services/rpc/trpc' import useUserInfo from '../services/user' +import useDevicesRouter from './data/devices' import useSourcesRouter from './data/sources' import useUserStoreRouter from './data/storage' -import useSwitchesRouter from './data/switches' import useTiesRouter from './data/ties' import useDriversRouter from './drivers' +import useMigrationRouter from './migration' import useSerialPortRouter from './ports' import useStartupRouter from './startup' import useSystemRouter from './system' @@ -23,9 +24,10 @@ export const useAppRouter = memo(() => system: useSystemRouter(), drivers: useDriversRouter(), // Data service routes + migration: useMigrationRouter(), storage: useUserStoreRouter(), ties: useTiesRouter(), - switches: useSwitchesRouter(), + devices: useDevicesRouter(), sources: useSourcesRouter(), updates: useUpdaterRouter() }) diff --git a/src/main/services/database.ts b/src/main/services/database.ts index 27c0ecb..2562e00 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -81,6 +81,18 @@ export function inferUpdatesOf(schema: Schema) { ) } +export type inferUpsertOf = Schema extends z.AnyZodObject + ? Simplify>>> + : never +export function inferUpsertOf(schema: Schema) { + return schema.and( + z.object({ + _id: DocumentId, + _attachments: z.array(z.instanceof(Attachment)).optional() + }) + ) +} + PouchDb.plugin(useLevelAdapter()) PouchDb.plugin(find) @@ -95,9 +107,10 @@ export class Database { declare readonly __raw__: z.infer /** The database document that is retrieved from the database. */ declare readonly __document__: inferDocumentOf - // typeof this.__raw__ & { _id: DocumentId, _rev: RevisionId, _attachments: Attachment[] } /** The possible document updates. */ declare readonly __updates__: inferUpdatesOf + /** The raw document is a predefined ID. */ + declare readonly __upsert__: inferUpsertOf /** * Initializes a new instance of the Database class. @@ -233,10 +246,34 @@ export class Database { }) } + /** Updates an existing document, or inserts a new one, with the given ID. */ + async upsert(document: Simplify, ...attachments: Attachment[]) { + return await this.run(async (db) => { + const id = document._id.toUpperCase() + const old = await this.getDoc(id).catch(() => ({ _rev: undefined, _attachments: undefined })) + const doc = old._rev + ? { ...this.#schema.parse(document), _id: id, _rev: old._rev } + : { ...this.#schema.parse(document), _id: id } + + const result = await db.put(doc) + /* v8 ignore next 1 */ // Likely trigger by database corruption. + if (!result.ok) throw new Error(`Failed to insert document "${doc._id}"`) + if (attachments.length > 0) { + await this.addAttachments(result.id, result.rev, attachments) + } else if (document._attachments != null && document._attachments.length > 0) { + await this.addAttachments(result.id, result.rev, document._attachments) + } else if (old._attachments != null) { + await this.addAttachments(result.id, result.rev, await prepareAttachments(old._attachments)) + } + + return await this.get(id) + }) + } + /** Updates an existing document in the database. */ async update(document: Simplify, ...attachments: Attachment[]) { return await this.run(async (db) => { - const id = document._id + const id = document._id.toUpperCase() const old = await this.getDoc(id) const doc = { ...this.#schema.parse({ ...old, ...document }), _id: id, _rev: old._rev } @@ -255,6 +292,29 @@ export class Database { }) } + /** Replaces an existing document, or inserts a new one, with the given ID */ + async replace(document: Simplify, ...attachments: Attachment[]) { + return await this.run(async (db) => { + const id = document._id.toUpperCase() + const old = await this.getDoc(id).catch(() => ({ _rev: undefined, _attachments: undefined })) + const doc = old._rev + ? { ...this.#schema.parse(document), _id: id, _rev: old._rev } + : { ...this.#schema.parse(document), _id: id } + + const result = await db.put(doc) + // Unlike upsert, we don't transfer the existing attachments in the database to the new revision. + /* v8 ignore next 1 */ // Likely trigger by database corruption. + if (!result.ok) throw new Error(`Failed to insert document "${doc._id}"`) + if (attachments.length > 0) { + await this.addAttachments(result.id, result.rev, attachments) + } else if (document._attachments != null && document._attachments.length > 0) { + await this.addAttachments(result.id, result.rev, document._attachments) + } + + return await this.get(id) + }) + } + /** Removes a document from the database. */ async remove(id: DocumentId, rev?: RevisionId) { await this.run(async (db) => { @@ -275,4 +335,18 @@ export class Database { await db.compact() }) } + + /** Deletes all data related to the database. */ + async destroy() { + await this.run(async (db) => { + await db.destroy() + }) + } + + /** Closes the database. */ + async close() { + await this.run(async (db) => { + await db.close() + }) + } } diff --git a/src/main/services/level.js b/src/main/services/level.js index 1723aa1..6ca3ca7 100644 --- a/src/main/services/level.js +++ b/src/main/services/level.js @@ -1,5 +1,6 @@ import { resolve as resolvePath } from 'node:path' import { app } from 'electron' +import Logger from 'electron-log' import levelDown from 'leveldown' import levelUp from 'levelup' // @ts-expect-error -- No types @@ -28,7 +29,7 @@ export const useLevelDb = memo(function useLevelDb() { app.on('will-quit', () => { db.close((err) => { /* v8 ignore next 1 */ // No way to spy or mock this deep in. - if (err != null) console.error(err) + if (err != null) Logger.error(err) }) }) diff --git a/src/main/services/migration.ts b/src/main/services/migration.ts new file mode 100644 index 0000000..e795a28 --- /dev/null +++ b/src/main/services/migration.ts @@ -0,0 +1,80 @@ +import { basename } from 'node:path' +import Logger from 'electron-log' +import { alphabetical, memo } from 'radash' +import { z } from 'zod' +import { useLevelDb } from './level' + +export type Migration = z.infer +export const Migration = z + .object({ + migrate: z.function(z.tuple([]), z.unknown()) + }) + .transform((m) => m.migrate) + +type State = z.infer +const State = z.enum(['missed', 'failed', 'done']) +const StateInDb = z + .union([z.instanceof(Buffer), State]) + /* v8 ignore next 1 */ // Generally one or the other most of the time. + .transform((v) => (Buffer.isBuffer(v) ? State.parse(v.toString()) : v)) + +const useMigrations = memo(function useMigrations() { + /** Gets the sorted list of migration modules. */ + function loadMigrations() { + const migrations = Object.entries(import.meta.glob('../migrations/**/*', { eager: true })).map( + ([name, module]) => ({ name, migrate: Migration.parse(module) }) + ) + + return alphabetical(migrations, (m) => m.name) + } + + /** Opens a connection to the migration database. */ + async function openMigrationDatabase() { + return await useLevelDb().levelup('_migrations') + } + + // Memoized so the UI can determine the results. + const performMigration = memo(async function performMigration() { + Logger.debug('Loading migration information') + const migrations = loadMigrations() + const database = await openMigrationDatabase() + + /* eslint-disable no-await-in-loop */ + for (const migration of migrations) { + const name = basename(migration.name, '.ts') + + const state = StateInDb.parse(await database.get(name).catch(() => 'missed')) + if (state === 'done') continue + + /* v8 ignore next 2 */ // Difficult to test without injecting a broken migration. + if (state === 'failed') { + Logger.warn(`Attempting failed migration again: ${name}`) + } else { + Logger.debug(`Attempting migration: ${name}`) + } + + try { + await migration.migrate() + /* v8 ignore next 4 */ // Difficult to test without injecting a broken migration. + } catch (cause) { + await database.put(name, 'failed') + throw new Error(`Failed to complete migration: ${name}`, { cause }) + } + + try { + await database.put(name, 'done') + /* v8 ignore next 4 */ // Difficult to test without injecting a broken migration. + } catch (cause) { + await database.put(name, 'failed') + throw new Error(`Failed to record migration completion: ${name}`, { cause }) + } + } + /* eslint-enable no-await-in-loop */ + + await database.close() + }) + + return performMigration +}) + +export default useMigrations diff --git a/src/main/services/rpc/trpc.ts b/src/main/services/rpc/trpc.ts index f0395ad..1a471fc 100644 --- a/src/main/services/rpc/trpc.ts +++ b/src/main/services/rpc/trpc.ts @@ -1,6 +1,6 @@ import { initTRPC } from '@trpc/server' -import useSuperJson from '@/rpc/transformer' +import { useIpcJson } from '@/rpc/transformer' -const t = initTRPC.create({ transformer: useSuperJson() }) +const t = initTRPC.create({ transformer: useIpcJson() }) export const { router, procedure } = t diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index b54c1ec..4ad82a5 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -13,9 +13,9 @@ export type { ApiLocales } from '../main/services/locale' export type { PortEntry } from '../main/services/ports' export type { UpdateInfo, ProgressInfo } from '../main/services/updater' -export type { Source, NewSource, SourceUpdate } from '../main/dao/sources' -export type { Switch, NewSwitch, SwitchUpdate } from '../main/dao/switches' -export type { Tie, NewTie, TieUpdate } from '../main/dao/ties' +export type { Source, NewSource, SourceUpdate, SourceUpsert } from '../main/dao/sources' +export type { Device, NewDevice, DeviceUpdate, DeviceUpsert } from '../main/dao/devices' +export type { Tie, NewTie, TieUpdate, TieUpsert } from '../main/dao/ties' export type { ApiLocales } from '../main/locale' export type { diff --git a/src/renderer/components/Highlight.tsx b/src/renderer/components/Highlight.tsx index 7d6ebc9..0d3b623 100644 --- a/src/renderer/components/Highlight.tsx +++ b/src/renderer/components/Highlight.tsx @@ -5,7 +5,7 @@ interface HighlightProps { search?: string } -export function Highlight({ text, search }: HighlightProps) { +export default function Highlight({ text, search }: HighlightProps) { if (!text) return h(Comment, 'Nothing') if (!search) return h(Text, text) const start = text.indexOf(search) diff --git a/src/renderer/hooks/location.ts b/src/renderer/hooks/location.ts index 054ba1a..c0ee473 100644 --- a/src/renderer/hooks/location.ts +++ b/src/renderer/hooks/location.ts @@ -3,7 +3,6 @@ import { toValue, computed, readonly } from 'vue' import { useI18n } from 'vue-i18n' import type { I18nSchema } from '../locales/locales' import type { PortEntry } from '../services/ports' -import type { NewSwitch } from '../services/switches' import type { LocationType } from '@/location' import type { MessageProps } from '@vuelidate/validators' import type { MaybeRefOrGetter, Ref } from 'vue' @@ -40,9 +39,12 @@ interface LocationTypeMetaData { value: LocationType } -export function useLocation(location: Ref, validSwitches: MaybeRefOrGetter) { +export default function useLocation( + location: Ref, + validPorts: MaybeRefOrGetter +) { const { t } = useI18n() - const { locationPath } = useLocationUtils(validSwitches) + const { locationPath } = useLocationUtils(validPorts) const pathTypes = readonly([ { title: t('label.remote'), value: 'ip' }, @@ -100,20 +102,3 @@ export function useLocation(location: Ref, validSwitches: Ma path } } - -export const useSwitchLocation = ( - switcher: MaybeRefOrGetter, - validSwitches: MaybeRefOrGetter -) => { - const location = computed({ - get: () => toValue(switcher).path, - set: (v) => { - toValue(switcher).path = v - } - }) - - return { - location, - ...useLocation(location, validSwitches) - } -} diff --git a/src/renderer/locales/en/messages.json b/src/renderer/locales/en/messages.json index d5aa957..51c02cb 100644 --- a/src/renderer/locales/en/messages.json +++ b/src/renderer/locales/en/messages.json @@ -25,6 +25,8 @@ "general": "General", "image": "Image", "loading": "Loading...", + "monitor": "Monitor", + "monitors": "Monitors", "name": "Name", "port": "Port", "remote": "Remote host", diff --git a/src/renderer/modals/SwitchDialog.vue b/src/renderer/modals/DeviceDialog.vue similarity index 92% rename from src/renderer/modals/SwitchDialog.vue rename to src/renderer/modals/DeviceDialog.vue index b4203ba..4af9498 100644 --- a/src/renderer/modals/SwitchDialog.vue +++ b/src/renderer/modals/DeviceDialog.vue @@ -3,14 +3,14 @@ import { mdiClose, mdiFlask } from '@mdi/js' import { useVModel } from '@vueuse/core' import { computed, ref, reactive, onBeforeMount } from 'vue' import { useI18n } from 'vue-i18n' -import { Highlight } from '../components/Highlight' -import { useLocation } from '../hooks/location' +import Highlight from '../components/Highlight' +import useLocation from '../hooks/location' import { useRules, useValidation } from '../hooks/validation' import useDrivers from '../services/driver' import usePorts from '../services/ports' -import { useDialogs, useSwitchDialog } from './dialogs' +import { useDialogs, useDeviceDialog } from './dialogs' import type { I18nSchema } from '../locales/locales' -import type { NewSwitch } from '../services/switches' +import type { NewDevice } from '../services/data/devices' import type { DeepReadonly } from 'vue' const props = defineProps<{ @@ -19,12 +19,12 @@ const props = defineProps<{ visible?: boolean // Form editing: boolean - switch: DeepReadonly + device: DeepReadonly }>() const emit = defineEmits<{ (on: 'update:visible', value: boolean): void - (on: 'confirm', value: NewSwitch): void + (on: 'confirm', value: NewDevice): void }>() const { t } = useI18n() @@ -39,7 +39,7 @@ const ports = usePorts() onBeforeMount(ports.all) // eslint-disable-next-line vue/no-setup-props-reactivity-loss -- Prop reactivity not desired. -const target = ref(structuredClone(props.switch)) +const target = ref(structuredClone(props.device)) const location = computed({ get: () => v$.path.$model, set: (v) => { @@ -90,7 +90,7 @@ const rules = reactive({ const { dirty, getStatus, submit, v$ } = useValidation(rules, target, confirm) -const { cardProps, isFullscreen, body, showDividers } = useSwitchDialog() +const { cardProps, isFullscreen, body, showDividers } = useDeviceDialog() const isBusy = computed(() => drivers.isBusy || ports.isBusy) @@ -142,7 +142,7 @@ const title = computed(() =>