refactor: rpc

This commit is contained in:
Ben Allfree 2022-11-21 02:08:55 -08:00
parent d8f030a3e2
commit 4e65a7b948
39 changed files with 613 additions and 425 deletions

View File

@ -5,6 +5,7 @@
"license": "MIT",
"dependencies": {
"@s-libs/micro-dash": "^14.1.0",
"ajv": "^8.11.2",
"nanoid": "^4.0.0",
"pocketbase": "^0.8.0"
}

View File

@ -35,19 +35,17 @@ export const createPromiseHelper = (config: PromiseHelperConfig) => {
return res
})
.catch((e: any) => {
error(pfx, JSON.stringify(e, null, 2))
if (e instanceof ClientResponseError) {
error(pfx, `PocketBase API error ${e}`)
error(pfx, JSON.stringify(e.data, null, 2))
if (e.status === 400) {
error(
pfx,
`It looks like you don't have permission to make this request.`
`PocketBase API error: It looks like you don't have permission to make this request.`
)
} else {
error(pfx, `Unknown PocketBase API error`)
}
} else {
error(pfx, `failed: ${e}`)
}
error(pfx, e)
throw e
})
}

View File

@ -1,11 +1,19 @@
import { customAlphabet } from 'nanoid'
import type pocketbaseEs from 'pocketbase'
import type { RecordSubscription } from 'pocketbase'
import {
ClientResponseError,
RecordSubscription,
UnsubscribeFunc,
} from 'pocketbase'
import type { JsonObject } from 'type-fest'
import { Logger } from '../Logger'
import { PromiseHelper } from '../PromiseHelper'
import { RecordId, RpcCommands, UserId } from '../schema'
import { BaseFields, RpcCommands, UserId } from '../schema'
import type { WatchHelper } from './WatchHelper'
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz')
export const newId = () => nanoid(15)
export type RpcHelperConfig = {
client: pocketbaseEs
watchHelper: WatchHelper
@ -26,16 +34,15 @@ export enum RpcStatus {
export type RpcPayloadBase = JsonObject
export type RpcRecord_In<TRecord extends RpcRecord<any, any>> = Pick<
export type RpcRecord_Create<TRecord extends RpcFields<any, any>> = Pick<
TRecord,
'userId' | 'payload' | 'cmd'
'id' | 'userId' | 'payload' | 'cmd'
>
export type RpcRecord<
export type RpcFields<
TPayload extends RpcPayloadBase,
TRes extends JsonObject
> = {
id: RecordId
> = BaseFields & {
userId: UserId
cmd: string
payload: TPayload
@ -51,12 +58,13 @@ export const createRpcHelper = (config: RpcHelperConfig) => {
client,
watchHelper: { watchById },
promiseHelper: { safeCatch },
logger: { dbg },
} = config
const mkRpc = <TPayload extends JsonObject, TResult extends JsonObject>(
cmd: RpcCommands
) => {
type ConcreteRpcRecord = RpcRecord<TPayload, TResult>
type ConcreteRpcRecord = RpcFields<TPayload, TResult>
return safeCatch(
cmd,
@ -69,39 +77,38 @@ export const createRpcHelper = (config: RpcHelperConfig) => {
throw new Error(`Expected authenticated user here.`)
}
const { id: userId } = _user
const rpcIn: RpcRecord_In<ConcreteRpcRecord> = {
const rpcIn: RpcRecord_Create<ConcreteRpcRecord> = {
id: newId(),
cmd,
userId,
payload,
}
const rec = await client
.collection(RPC_COLLECTION)
.create<ConcreteRpcRecord>(rpcIn)
dbg({ rpcIn })
let unsub: UnsubscribeFunc | undefined
return new Promise<ConcreteRpcRecord['result']>(
async (resolve, reject) => {
const unsub = watchById<ConcreteRpcRecord>(
unsub = await watchById<ConcreteRpcRecord>(
RPC_COLLECTION,
rec.id,
rpcIn.id,
(data) => {
dbg(`Got an RPC change`, data)
cb?.(data)
if (data.record.status === RpcStatus.FinishedSuccess) {
unsub.then((u) => {
u()
resolve(data.record.result)
})
resolve(data.record.result)
return
}
if (data.record.status === RpcStatus.FinishedError) {
unsub.then((u) => {
reject(data.record.message)
u()
})
reject(new ClientResponseError(data.record.result))
return
}
cb?.(data)
}
},
false
)
await client.collection(RPC_COLLECTION).create(rpcIn)
}
)
).finally(async () => {
await unsub?.()
})
}
)
}

View File

@ -1,4 +1,4 @@
import type { RecordId } from '@pockethost/common'
import type { BaseFields, RecordId } from '@pockethost/common'
import type pocketbaseEs from 'pocketbase'
import type { RecordSubscription, UnsubscribeFunc } from 'pocketbase'
import { Logger } from '../Logger'
@ -16,44 +16,61 @@ export const createWatchHelper = (config: WatchHelperConfig) => {
const {
client,
promiseHelper: { safeCatch },
logger: { dbg },
} = config
const watchById = safeCatch(
`subscribe`,
`watchById`,
async <TRec>(
collectionName: string,
id: RecordId,
cb: (data: RecordSubscription<TRec>) => void,
initialFetch = true
) => {
): Promise<UnsubscribeFunc> => {
dbg(`watching ${collectionName}:${id}`)
let hasUpdate = false
const unsub = await client
.collection(collectionName)
.subscribe<TRec>(id, cb)
.subscribe<TRec>(id, (e) => {
hasUpdate = true
dbg(`Got an update watching ${collectionName}:${id}`, e)
cb(e)
})
if (initialFetch) {
const initial = await client.collection(collectionName).getOne<TRec>(id)
if (!initial) {
throw new Error(`Expected ${collectionName}.${id} to exist.`)
}
cb({ action: 'update', record: initial })
if (!hasUpdate) {
// No update has been sent yet, send at least one
dbg(`Sending initial update for ${collectionName}:${id}`, initial)
cb({ action: 'initial', record: initial })
}
}
return async () => {
dbg(`UNsubbing ${collectionName}:${id}`)
await unsub()
}
return unsub
}
)
const watchAllById = safeCatch(
`watchAllById`,
async <TRec>(
async <TRec extends BaseFields>(
collectionName: string,
idName: keyof TRec,
idValue: RecordId,
cb: (data: RecordSubscription<TRec>) => void,
initialFetch = true
): Promise<UnsubscribeFunc> => {
let hasUpdate: { [_: RecordId]: boolean } = {}
const unsub = client
.collection(collectionName)
.subscribe<TRec>('*', (e) => {
// console.log(e.record.instanceId, id)
if (e.record[idName] !== idValue) return
hasUpdate[e.record.id] = true
cb(e)
})
if (initialFetch) {
@ -62,7 +79,10 @@ export const createWatchHelper = (config: WatchHelperConfig) => {
.getFullList<TRec>(100, {
filter: `${idName.toString()} = '${idValue}'`,
})
existing.forEach((record) => cb({ action: 'init', record }))
existing.forEach((record) => {
if (hasUpdate[record.id]) return
cb({ action: 'initial', record })
})
}
return unsub
}

View File

@ -1,4 +1,4 @@
import { InstanceId, IsoDate, RecordId } from './types'
import { BaseFields, InstanceId, RecordId } from './types'
export enum BackupStatus {
Queued = 'queued',
@ -8,14 +8,12 @@ export enum BackupStatus {
}
export type BackupRecordId = RecordId
export type BackupRecord = {
id: BackupRecordId
export type BackupFields = BaseFields & {
instanceId: InstanceId
status: BackupStatus
message: string
bytes: number
created: IsoDate
updated: IsoDate
platform: string
version: string
progress: {
@ -23,14 +21,14 @@ export type BackupRecord = {
}
}
export type BackupRecord_Create = Pick<
BackupRecord,
export type BackupFields_Create = Pick<
BackupFields,
'instanceId' | 'status' | 'platform' | 'version'
>
export type BackupRecord_Update = Partial<
export type BackupFields_Update = Partial<
Pick<
BackupRecord,
BackupFields,
| 'instanceId'
| 'status'
| 'bytes'

View File

@ -1,5 +1,5 @@
import { PlatformId, VersionId } from '../releases'
import { RecordId, Seconds, Subdomain, UserId } from './types'
import { BaseFields, RecordId, Seconds, Subdomain, UserId } from './types'
export enum InstanceStatus {
Unknown = '',
@ -10,17 +10,16 @@ export enum InstanceStatus {
Failed = 'failed',
}
export type InstancesRecord = {
id: RecordId
export type InstanceFields = BaseFields & {
subdomain: Subdomain
uid: UserId
status: InstanceStatus
platform: PlatformId
version: VersionId
secondsThisMonth: Seconds
isBackupAllowed: boolean
}
export type InstancesRecord_New = Omit<
InstancesRecord,
'id' | 'secondsThisMonth'
>
export type InstanceFields_Create = Omit<InstanceFields, keyof BaseFields>
export type InstanceRecordsById = { [_: RecordId]: InstanceFields }

View File

@ -1,7 +1,6 @@
import { IsoDate, RecordId } from './types'
import { BaseFields, IsoDate, RecordId } from './types'
export type InvocationRecord = {
id: RecordId
export type InvocationFields = BaseFields & {
instanceId: RecordId
startedAt: IsoDate
endedAt: IsoDate

View File

@ -1,46 +0,0 @@
import { BackupRecordId } from './Backup'
import { InstancesRecord } from './Instance'
import { InstanceId, RecordId, UserId } from './types'
export enum JobStatus {
New = 'new',
Queued = 'queued',
Running = 'running',
FinishedSuccess = 'finished-success',
FinishedError = 'finished-error',
}
export type JobPayloadBase = {
cmd: JobCommands
}
export enum JobCommands {
BackupInstance = 'backup-instance',
RestoreInstance = 'restore-instance',
}
export const JOB_COMMANDS = [JobCommands.BackupInstance]
export type InstanceBackupJobPayload = {
cmd: JobCommands.BackupInstance
instanceId: InstanceId
}
export type InstanceRestoreJobPayload = {
cmd: JobCommands.RestoreInstance
backupId: BackupRecordId
}
export type JobRecord<TPayload> = {
id: RecordId
userId: UserId
payload: TPayload
status: JobStatus
message: string
}
export type InstanceBackupJobRecord = JobRecord<InstanceBackupJobPayload>
export type JobRecord_In<TPayload> = Omit<JobRecord<TPayload>, 'id' | 'message'>
export type InstanceRecordById = { [_: InstanceId]: InstancesRecord }

View File

@ -0,0 +1,18 @@
import { JSONSchemaType } from 'ajv'
import { InstanceId, RecordId } from '../types'
export type BackupInstancePayload = {
instanceId: InstanceId
}
export const BackupInstancePayloadSchema: JSONSchemaType<BackupInstancePayload> =
{
type: 'object',
properties: {
instanceId: { type: 'string' },
},
required: ['instanceId'],
additionalProperties: false,
}
export type BackupInstanceResult = { backupId: RecordId }

View File

@ -0,0 +1,21 @@
import { JSONSchemaType } from 'ajv'
import { InstanceFields } from '../Instance'
import { Subdomain } from '../types'
export type CreateInstancePayload = {
subdomain: Subdomain
}
export type CreateInstanceResult = {
instance: InstanceFields
}
export const CreateInstancePayloadSchema: JSONSchemaType<CreateInstancePayload> =
{
type: 'object',
properties: {
subdomain: { type: 'string' },
},
required: ['subdomain'],
additionalProperties: false,
}

View File

@ -0,0 +1,20 @@
import { JSONSchemaType } from 'ajv'
import { InstanceId, RecordId } from '../types'
export type RestoreInstancePayload = {
instanceId: InstanceId
backupId: RecordId
}
export type RestoreInstanceResult = { restoreId: RecordId }
export const RestoreInstancePayloadSchema: JSONSchemaType<RestoreInstancePayload> =
{
type: 'object',
properties: {
instanceId: { type: 'string' },
backupId: { type: 'string' },
},
required: ['instanceId', 'backupId'],
additionalProperties: false,
}

View File

@ -0,0 +1,14 @@
export enum RpcCommands {
CreateInstance = 'create-instance',
BackupInstance = 'backup-instance',
RestoreInstance = 'restore-instance',
}
export const RPC_COMMANDS = [
RpcCommands.BackupInstance,
RpcCommands.CreateInstance,
]
export * from './BackupInstance'
export * from './CreateInstance'
export * from './RestoreInstance'

View File

@ -1,7 +1,6 @@
import { RecordId } from './types'
import { BaseFields } from './types'
export type UserRecord = {
id: RecordId
export type UserFields = BaseFields & {
email: string
verified: boolean
}

View File

@ -1,7 +1,7 @@
export * from './Backup'
export * from './Instance'
export * from './Invocation'
export * from './Job'
export * from './Rpc'
export * from './types'
export * from './User'
export * from './util'

View File

@ -10,3 +10,8 @@ export type Username = string
export type Password = string
export type CollectionName = string
export type Seconds = number
export type BaseFields = {
id: RecordId
created: IsoDate
updated: IsoDate
}

View File

@ -13,6 +13,7 @@
"@s-libs/micro-dash": "^14.1.0",
"@types/http-proxy": "^1.17.9",
"@types/node": "^18.11.9",
"ajv": "^8.11.2",
"boolean": "^3.2.0",
"bottleneck": "^2.19.5",
"date-fns": "^2.29.3",

View File

@ -1,11 +1,11 @@
import {
BackupRecord,
BackupFields,
BackupFields_Create,
BackupFields_Update,
BackupRecordId,
BackupRecord_Create,
BackupRecord_Update,
BackupStatus,
InstanceFields,
InstanceId,
InstancesRecord,
} from '@pockethost/common'
import { safeCatch } from '../util/promiseHelper'
import { MixinContext } from './PbClient'
@ -20,12 +20,12 @@ export const createBackupMixin = (context: MixinContext) => {
async (instanceId: InstanceId) => {
const instance = await client
.collection('instances')
.getOne<InstancesRecord>(instanceId)
.getOne<InstanceFields>(instanceId)
if (!instance) {
throw new Error(`Expected ${instanceId} to be a valid instance`)
}
const { platform, version } = instance
const rec: BackupRecord_Create = {
const rec: BackupFields_Create = {
instanceId,
status: BackupStatus.Queued,
platform,
@ -33,14 +33,14 @@ export const createBackupMixin = (context: MixinContext) => {
}
const created = await client
.collection('backups')
.create<BackupRecord>(rec)
.create<BackupFields>(rec)
return created
}
)
const updateBackup = safeCatch(
`updateBackup`,
async (backupId: BackupRecordId, fields: BackupRecord_Update) => {
async (backupId: BackupRecordId, fields: BackupFields_Update) => {
await client.collection('backups').update(backupId, fields)
}
)
@ -57,7 +57,7 @@ export const createBackupMixin = (context: MixinContext) => {
const getNextBackupJob = safeCatch(`getNextBackupJob`, async () => {
return client
.collection('backups')
.getList<BackupRecord>(1, 1, {
.getList<BackupFields>(1, 1, {
filter: `status = '${BackupStatus.Queued}'`,
})
.then((recs) => {
@ -68,7 +68,7 @@ export const createBackupMixin = (context: MixinContext) => {
const getBackupJob = safeCatch(
`getBackupJob`,
async (backupId: BackupRecordId) => {
return client.collection('backups').getOne<BackupRecord>(backupId)
return client.collection('backups').getOne<BackupFields>(backupId)
}
)

View File

@ -1,9 +1,10 @@
import {
assertExists,
InstanceFields,
InstanceFields_Create,
InstanceId,
InstancesRecord,
InstanceStatus,
UserRecord,
UserFields,
} from '@pockethost/common'
import { reduce } from '@s-libs/micro-dash'
import Bottleneck from 'bottleneck'
@ -17,17 +18,24 @@ export type InstanceApi = ReturnType<typeof createInstanceMixin>
export const createInstanceMixin = (context: MixinContext) => {
const { client, rawDb } = context
const createInstance = safeCatch(
`createInstance`,
(payload: InstanceFields_Create): Promise<InstanceFields> => {
return client.collection('instances').create<InstanceFields>(payload)
}
)
const getInstanceBySubdomain = safeCatch(
`getInstanceBySubdomain`,
(subdomain: string): Promise<[InstancesRecord, UserRecord] | []> =>
(subdomain: string): Promise<[InstanceFields, UserFields] | []> =>
client
.collection('instances')
.getFirstListItem<InstancesRecord>(`subdomain = '${subdomain}'`)
.getFirstListItem<InstanceFields>(`subdomain = '${subdomain}'`)
.then((instance) => {
if (!instance) return []
return client
.collection('users')
.getOne<UserRecord>(instance.uid)
.getOne<UserFields>(instance.uid)
.then((user) => {
return [instance, user]
})
@ -36,7 +44,7 @@ export const createInstanceMixin = (context: MixinContext) => {
const updateInstance = safeCatch(
`updateInstance`,
async (instanceId: InstanceId, fields: Partial<InstancesRecord>) => {
async (instanceId: InstanceId, fields: Partial<InstanceFields>) => {
await client.collection('instances').update(instanceId, fields)
}
)
@ -51,16 +59,16 @@ export const createInstanceMixin = (context: MixinContext) => {
const getInstance = safeCatch(
`getInstance`,
async (instanceId: InstanceId) => {
return client.collection('instances').getOne<InstancesRecord>(instanceId)
return client.collection('instances').getOne<InstanceFields>(instanceId)
}
)
const updateInstances = safeCatch(
'updateInstances',
async (cb: (rec: InstancesRecord) => Partial<InstancesRecord>) => {
async (cb: (rec: InstanceFields) => Partial<InstanceFields>) => {
const res = await client
.collection('instances')
.getFullList<InstancesRecord>(200)
.getFullList<InstanceFields>(200)
const limiter = new Bottleneck({ maxConcurrent: 1 })
const promises = reduce(
res,
@ -106,5 +114,6 @@ export const createInstanceMixin = (context: MixinContext) => {
getInstance,
updateInstanceSeconds,
updateInstances,
createInstance,
}
}

View File

@ -1,8 +1,4 @@
import {
InstancesRecord,
InvocationRecord,
pocketNow,
} from '@pockethost/common'
import { InstanceFields, InvocationFields, pocketNow } from '@pockethost/common'
import { dbg } from '../util/logger'
import { safeCatch } from '../util/promiseHelper'
import { InstanceApi } from './InstanceMIxin'
@ -16,8 +12,8 @@ export const createInvocationMixin = (
const createInvocation = safeCatch(
`createInvocation`,
async (instance: InstancesRecord, pid: number) => {
const init: Partial<InvocationRecord> = {
async (instance: InstanceFields, pid: number) => {
const init: Partial<InvocationFields> = {
startedAt: pocketNow(),
pid,
instanceId: instance.id,
@ -25,22 +21,22 @@ export const createInvocationMixin = (
}
const _inv = await client
.collection('invocations')
.create<InvocationRecord>(init)
.create<InvocationFields>(init)
return _inv
}
)
const pingInvocation = safeCatch(
`pingInvocation`,
async (invocation: InvocationRecord) => {
async (invocation: InvocationFields) => {
const totalSeconds =
(+new Date() - Date.parse(invocation.startedAt)) / 1000
const toUpdate: Partial<InvocationRecord> = {
const toUpdate: Partial<InvocationFields> = {
totalSeconds,
}
const _inv = await client
.collection('invocations')
.update<InvocationRecord>(invocation.id, toUpdate)
.update<InvocationFields>(invocation.id, toUpdate)
await instanceApi.updateInstanceSeconds(invocation.instanceId)
return _inv
}
@ -48,18 +44,18 @@ export const createInvocationMixin = (
const finalizeInvocation = safeCatch(
`finalizeInvocation`,
async (invocation: InvocationRecord) => {
async (invocation: InvocationFields) => {
dbg('finalizing')
const totalSeconds =
(+new Date() - Date.parse(invocation.startedAt)) / 1000
const toUpdate: Partial<InvocationRecord> = {
const toUpdate: Partial<InvocationFields> = {
endedAt: pocketNow(),
totalSeconds,
}
dbg({ toUpdate })
const _inv = await client
.collection('invocations')
.update<InvocationRecord>(invocation.id, toUpdate)
.update<InvocationFields>(invocation.id, toUpdate)
await instanceApi.updateInstanceSeconds(invocation.instanceId)
return _inv
}

View File

@ -1,60 +0,0 @@
import { JobRecord, JobStatus } from '@pockethost/common'
import { safeCatch } from '../util/promiseHelper'
import { MixinContext } from './PbClient'
export enum RecordSubscriptionActions {
Create = 'create',
Update = 'update',
Delete = 'delete',
}
export const createJobMixin = (context: MixinContext) => {
const { client, rawDb } = context
const onNewJob = safeCatch(
`onNewJob`,
async (cb: (e: JobRecord<any>) => void) => {
const unsub = await client
.collection('jobs')
.subscribe<JobRecord<any>>('*', (e) => {
if (e.action !== RecordSubscriptionActions.Create) return
cb(e.record)
})
return unsub
}
)
const resetJobs = safeCatch(`resetJobs`, async () =>
rawDb('jobs')
.whereNotIn('status', [
JobStatus.FinishedError,
JobStatus.FinishedSuccess,
])
.update({
status: JobStatus.New,
})
)
const incompleteJobs = safeCatch(`incompleteJobs`, async () => {
return client.collection('jobs').getFullList<JobRecord<any>>(100, {
filter: `status != '${JobStatus.FinishedError}' && status != '${JobStatus.FinishedSuccess}'`,
})
})
const rejectJob = safeCatch(
`rejectJob`,
async (job: JobRecord<any>, message: string) => {
return client
.collection('jobs')
.update(job.id, { status: JobStatus.FinishedError, message })
}
)
const setJobStatus = safeCatch(
`setJobStatus`,
async (job: JobRecord<any>, status: JobStatus) => {
return client.collection('jobs').update(job.id, { status })
}
)
return { incompleteJobs, resetJobs, onNewJob, rejectJob, setJobStatus }
}

View File

@ -11,8 +11,8 @@ import { safeCatch } from '../util/promiseHelper'
import { createBackupMixin } from './BackupMixin'
import { createInstanceMixin } from './InstanceMIxin'
import { createInvocationMixin } from './InvocationMixin'
import { createJobMixin } from './JobMixin'
import { createRawPbClient } from './RawPbClient'
import { createRpcHelper } from './RpcHelper'
export type PocketbaseClientApi = ReturnType<typeof createPbClient>
@ -40,7 +40,7 @@ export const createPbClient = (url: string) => {
)
const context: MixinContext = { client, rawDb }
const jobsApi = createJobMixin(context)
const rpcApi = createRpcHelper(context)
const instanceApi = createInstanceMixin(context)
const backupApi = createBackupMixin(context)
const invocationApi = createInvocationMixin(context, instanceApi)
@ -50,7 +50,7 @@ export const createPbClient = (url: string) => {
knex: rawDb,
adminAuthViaEmail,
applySchema,
...jobsApi,
...rpcApi,
...instanceApi,
...invocationApi,
...backupApi,

View File

@ -0,0 +1,83 @@
import { RpcFields, RpcStatus, RPC_COLLECTION } from '@pockethost/common'
import { JsonObject } from 'type-fest'
import { safeCatch } from '../util/promiseHelper'
import { MixinContext } from './PbClient'
export enum RecordSubscriptionActions {
Create = 'create',
Update = 'update',
Delete = 'delete',
}
export type RpcHelperConfig = MixinContext
export type RpcHelper = ReturnType<typeof createRpcHelper>
export const createRpcHelper = (config: RpcHelperConfig) => {
const { client, rawDb } = config
const onNewRpc = safeCatch(
`onNewRpc`,
async (cb: (e: RpcFields<any, any>) => void) => {
const unsub = await client
.collection(RPC_COLLECTION)
.subscribe<RpcFields<any, any>>('*', (e) => {
if (e.action !== RecordSubscriptionActions.Create) return
cb(e.record)
})
return unsub
}
)
const resetRpcs = safeCatch(`resetRpcs`, async () =>
rawDb(RPC_COLLECTION)
.whereNotIn('status', [
RpcStatus.FinishedError,
RpcStatus.FinishedSuccess,
])
.update({
status: RpcStatus.New,
})
)
const incompleteRpcs = safeCatch(`incompleteRpcs`, async () => {
return client
.collection(RPC_COLLECTION)
.getFullList<RpcFields<any, any>>(100, {
filter: `status != '${RpcStatus.FinishedError}' && status != '${RpcStatus.FinishedSuccess}'`,
})
})
const rejectRpc = safeCatch(
`rejectRpc`,
async (rpc: RpcFields<any, any>, err: Error) => {
const fields: Partial<RpcFields<any, any>> = {
status: RpcStatus.FinishedError,
result: JSON.stringify(err),
}
return client
.collection(RPC_COLLECTION)
.update<RpcFields<any, any>>(rpc.id, fields)
}
)
const setRpcStatus = safeCatch(
`setRpcStatus`,
async (
rpc: RpcFields<any, any>,
status: RpcStatus,
result: JsonObject = {}
) => {
return client
.collection(RPC_COLLECTION)
.update(rpc.id, { status, result })
}
)
return {
incompleteRpcs,
resetRpcs,
onNewRpc,
rejectRpc,
setRpcStatus,
}
}

View File

@ -88,10 +88,19 @@ export const schema: Collection_Serialized[] = [
max: null,
},
},
{
id: '66vjgzcg',
name: 'isBackupAllowed',
type: 'bool',
system: false,
required: false,
unique: false,
options: {},
},
],
listRule: 'uid=@request.auth.id',
viewRule: 'uid = @request.auth.id',
createRule: "uid = @request.auth.id && (status = 'idle')",
createRule: null,
updateRule: null,
deleteRule: null,
options: {},
@ -229,7 +238,7 @@ export const schema: Collection_Serialized[] = [
},
{
id: 'v7s41iokt1vizxd',
name: 'jobs',
name: 'rpc',
type: 'base',
system: false,
schema: [
@ -260,7 +269,7 @@ export const schema: Collection_Serialized[] = [
name: 'status',
type: 'text',
system: false,
required: true,
required: false,
unique: false,
options: {
min: null,
@ -269,12 +278,21 @@ export const schema: Collection_Serialized[] = [
},
},
{
id: 'feovwsbr',
name: 'message',
type: 'text',
id: 'nd7cwqmn',
name: 'result',
type: 'json',
system: false,
required: false,
unique: false,
options: {},
},
{
id: '2hlrcx5j',
name: 'cmd',
type: 'text',
system: false,
required: true,
unique: false,
options: {
min: null,
max: null,
@ -284,7 +302,8 @@ export const schema: Collection_Serialized[] = [
],
listRule: 'userId = @request.auth.id',
viewRule: 'userId = @request.auth.id',
createRule: "userId = @request.auth.id && status='new'",
createRule:
"userId = @request.auth.id && status='' && (cmd='backup-instance' || cmd='restore-instance' || cmd='create-instance') && payload!='' && result=''",
updateRule: null,
deleteRule: null,
options: {},

View File

@ -10,8 +10,8 @@ import {
import { createPbClient } from './db/PbClient'
import { createBackupService } from './services/BackupService'
import { createInstanceService } from './services/InstanceService'
import { createJobService } from './services/JobService'
import { createProxyService } from './services/ProxyService'
import { createRpcService } from './services/RpcService'
import { mkInternalUrl } from './util/internal'
import { dbg, error, info } from './util/logger'
import { spawnInstance } from './util/spawnInstance'
@ -34,7 +34,6 @@ global.EventSource = require('eventsource')
* Launch services
*/
const client = createPbClient(coreInternalUrl)
const instanceService = await createInstanceService(client)
try {
await client.adminAuthViaEmail(DAEMON_PB_USERNAME, DAEMON_PB_PASSWORD)
dbg(`Logged in`)
@ -45,15 +44,16 @@ global.EventSource = require('eventsource')
error(`***WARNING*** LOG IN MANUALLY, ADJUST .env, AND RESTART DOCKER`)
}
const rpcService = await createRpcService({ client })
const instanceService = await createInstanceService({ client, rpcService })
const proxyService = await createProxyService(instanceService)
const jobService = await createJobService(client)
const backupService = await createBackupService(client, jobService)
const backupService = await createBackupService(client, rpcService)
process.once('SIGUSR2', async () => {
info(`SIGUSR2 detected`)
proxyService.shutdown()
instanceService.shutdown()
jobService.shutdown()
rpcService.shutdown()
backupService.shutdown()
})
})()

View File

@ -1,51 +1,56 @@
import {
assertTruthy,
BackupRecord,
BackupFields,
BackupInstancePayload,
BackupInstancePayloadSchema,
BackupInstanceResult,
BackupStatus,
createTimerManager,
InstanceBackupJobPayload,
InstanceRestoreJobPayload,
JobCommands,
RestoreInstancePayload,
RestoreInstancePayloadSchema,
RestoreInstanceResult,
RpcCommands,
} from '@pockethost/common'
import Bottleneck from 'bottleneck'
import { PocketbaseClientApi } from '../db/PbClient'
import { backupInstance } from '../util/backupInstance'
import { dbg } from '../util/logger'
import { JobServiceApi } from './JobService'
import { RpcServiceApi } from './RpcService'
export const createBackupService = async (
client: PocketbaseClientApi,
jobService: JobServiceApi
jobService: RpcServiceApi
) => {
jobService.registerCommand<InstanceBackupJobPayload>(
JobCommands.BackupInstance,
async (unsafeJob) => {
const unsafePayload = unsafeJob.payload
const { instanceId } = unsafePayload
assertTruthy(instanceId, `Expected instanceId here`)
jobService.registerCommand<BackupInstancePayload, BackupInstanceResult>(
RpcCommands.BackupInstance,
BackupInstancePayloadSchema,
async (job) => {
const { payload } = job
const { instanceId } = payload
const instance = await client.getInstance(instanceId)
assertTruthy(instance, `Instance ${instanceId} not found`)
assertTruthy(
instance.uid === unsafeJob.userId,
`Instance ${instanceId} is not owned by user ${unsafeJob.userId}`
instance.uid === job.userId,
`Instance ${instanceId} is not owned by user ${job.userId}`
)
await client.createBackup(instance.id)
const backup = await client.createBackup(instance.id)
return { backupId: backup.id }
}
)
jobService.registerCommand<InstanceRestoreJobPayload>(
JobCommands.RestoreInstance,
async (unsafeJob) => {
const unsafePayload = unsafeJob.payload
const { backupId } = unsafePayload
assertTruthy(backupId, `Expected backupId here`)
jobService.registerCommand<RestoreInstancePayload, RestoreInstanceResult>(
RpcCommands.RestoreInstance,
RestoreInstancePayloadSchema,
async (job) => {
const { payload } = job
const { backupId } = payload
const backup = await client.getBackupJob(backupId)
assertTruthy(backup, `Backup ${backupId} not found`)
const instance = await client.getInstance(backup.instanceId)
assertTruthy(instance, `Instance ${backup.instanceId} not found`)
assertTruthy(
instance.uid === unsafeJob.userId,
`Backup ${backupId} is not owned by user ${unsafeJob.userId}`
instance.uid === job.userId,
`Backup ${backupId} is not owned by user ${job.userId}`
)
/**
@ -57,7 +62,8 @@ export const createBackupService = async (
* 4. Restore
* 5. Lift maintenance mode
*/
await client.createBackup(instance.id)
const restore = await client.createBackup(instance.id)
return { restoreId: restore.id }
}
)
@ -70,7 +76,7 @@ export const createBackupService = async (
return true
}
const instance = await client.getInstance(backupRec.instanceId)
const _update = (fields: Partial<BackupRecord>) =>
const _update = (fields: Partial<BackupFields>) =>
limiter.schedule(() => client.updateBackup(backupRec.id, fields))
try {
await _update({

View File

@ -1,9 +1,15 @@
import {
assertTruthy,
binFor,
CreateInstancePayload,
CreateInstancePayloadSchema,
CreateInstanceResult,
createTimerManager,
InstanceId,
InstanceStatus,
LATEST_PLATFORM,
RpcCommands,
USE_LATEST_VERSION,
} from '@pockethost/common'
import { forEachRight, map } from '@s-libs/micro-dash'
import Bottleneck from 'bottleneck'
@ -21,6 +27,7 @@ import { dbg, error, warn } from '../util/logger'
import { now } from '../util/now'
import { safeCatch } from '../util/promiseHelper'
import { PocketbaseProcess, spawnInstance } from '../util/spawnInstance'
import { RpcServiceApi } from './RpcService'
type InstanceApi = {
process: PocketbaseProcess
@ -30,8 +37,35 @@ type InstanceApi = {
startRequest: () => () => void
}
export type InstanceServiceConfig = {
client: PocketbaseClientApi
rpcService: RpcServiceApi
}
export type InstanceServiceApi = AsyncReturnType<typeof createInstanceService>
export const createInstanceService = async (client: PocketbaseClientApi) => {
export const createInstanceService = async (config: InstanceServiceConfig) => {
const { client, rpcService } = config
const { registerCommand } = rpcService
registerCommand<CreateInstancePayload, CreateInstanceResult>(
RpcCommands.CreateInstance,
CreateInstancePayloadSchema,
async (rpc) => {
const { payload } = rpc
const { subdomain } = payload
const instance = await client.createInstance({
subdomain,
uid: rpc.userId,
version: USE_LATEST_VERSION,
status: InstanceStatus.Idle,
platform: LATEST_PLATFORM,
secondsThisMonth: 0,
isBackupAllowed: false,
})
return { instance }
}
)
const instances: { [_: string]: InstanceApi } = {}
const limiter = new Bottleneck({ maxConcurrent: 1 })

View File

@ -1,84 +0,0 @@
import {
assertTruthy,
JobCommands,
JobPayloadBase,
JobRecord,
JobStatus,
} from '@pockethost/common'
import { isObject } from '@s-libs/micro-dash'
import Bottleneck from 'bottleneck'
import { default as knexFactory } from 'knex'
import pocketbaseEs from 'pocketbase'
import { AsyncReturnType } from 'type-fest'
import { PocketbaseClientApi } from '../db/PbClient'
import { dbg, error } from '../util/logger'
export type JobServiceApi = AsyncReturnType<typeof createJobService>
export type KnexApi = ReturnType<typeof knexFactory>
export type CommandModuleInitializer = (
register: JobServiceApi['registerCommand'],
client: pocketbaseEs,
knex: KnexApi
) => void
export type JobHandler<TPayload> = (
unsafeJob: JobRecord<Partial<TPayload>>
) => Promise<void>
export const createJobService = async (client: PocketbaseClientApi) => {
const limiter = new Bottleneck({ maxConcurrent: 1 })
const jobHandlers: {
[_ in JobCommands]?: JobHandler<any>
} = {}
const run = async (job: JobRecord<any>) =>
limiter.schedule(async () => {
try {
await client.setJobStatus(job, JobStatus.Queued)
const { payload } = job
assertTruthy(isObject(payload), `Payload must be an object`)
const unsafePayload = payload as Partial<JobPayloadBase>
const { cmd } = unsafePayload
assertTruthy(cmd, `Payload must contain command`)
const handler = jobHandlers[cmd]
if (!handler) {
throw new Error(`Job handler ${cmd} is not registered`)
}
dbg(`Running job ${job.id}`, job)
await client.setJobStatus(job, JobStatus.Running)
await handler(job)
await client.setJobStatus(job, JobStatus.FinishedSuccess)
} catch (e) {
await client.rejectJob(job, `${e}`).catch((e) => {
error(`job ${job.id} failed to reject with ${e}`)
})
}
})
const unsub = await client.onNewJob(run)
await client.resetJobs()
await client.resetBackups()
const jobs = await client.incompleteJobs()
jobs.forEach(run)
const shutdown = () => {
unsub()
}
const registerCommand = <TPayload>(
commandName: JobCommands,
handler: JobHandler<TPayload>
) => {
if (jobHandlers[commandName]) {
throw new Error(`${commandName} job handler already registered.`)
}
jobHandlers[commandName] = handler
}
return {
registerCommand,
shutdown,
}
}

View File

@ -0,0 +1,125 @@
import {
assertTruthy,
RpcCommands,
RpcFields,
RpcStatus,
RPC_COMMANDS,
} from '@pockethost/common'
import { isObject } from '@s-libs/micro-dash'
import Ajv, { JSONSchemaType, ValidateFunction } from 'ajv'
import Bottleneck from 'bottleneck'
import { default as knexFactory } from 'knex'
import pocketbaseEs from 'pocketbase'
import { AsyncReturnType, JsonObject } from 'type-fest'
import { PocketbaseClientApi } from '../db/PbClient'
import { dbg, error } from '../util/logger'
export type RpcServiceApi = AsyncReturnType<typeof createRpcService>
export type KnexApi = ReturnType<typeof knexFactory>
export type CommandModuleInitializer = (
register: RpcServiceApi['registerCommand'],
client: pocketbaseEs,
knex: KnexApi
) => void
export type RpcRunner<
TPayload extends JsonObject,
TResult extends JsonObject
> = (job: RpcFields<TPayload, TResult>) => Promise<TResult>
export type RpcServiceConfig = { client: PocketbaseClientApi }
export const createRpcService = async (config: RpcServiceConfig) => {
const { client } = config
const limiter = new Bottleneck({ maxConcurrent: 1 })
const jobHandlers: {
[_ in RpcCommands]?: {
validate: ValidateFunction<any>
run: RpcRunner<any, any>
}
} = {}
const run = async (rpc: RpcFields<any, any>) => {
await client.setRpcStatus(rpc, RpcStatus.Queued)
return limiter.schedule(async () => {
try {
dbg(`Starting job ${rpc.id} (${rpc.cmd})`, JSON.stringify(rpc))
await client.setRpcStatus(rpc, RpcStatus.Starting)
const cmd = (() => {
const { cmd } = rpc
if (!RPC_COMMANDS.find((c) => c === cmd)) {
throw new Error(
`RPC command '${cmd}' is invalid. It must be one of: ${RPC_COMMANDS.join(
'|'
)}.`
)
}
return cmd as RpcCommands
})()
const handler = jobHandlers[cmd]
if (!handler) {
throw new Error(`RPC handler ${cmd} is not registered`)
}
const { payload } = rpc
assertTruthy(isObject(payload), `Payload must be an object`)
const { validate, run } = handler
if (!validate(payload)) {
throw new Error(
`Payload for ${cmd} fails validation: ${JSON.stringify(payload)}`
)
}
dbg(`Running RPC ${rpc.id}`, rpc)
await client.setRpcStatus(rpc, RpcStatus.Running)
const res = await run(rpc)
await client.setRpcStatus(rpc, RpcStatus.FinishedSuccess, res)
} catch (e) {
if (!(e instanceof Error)) {
throw new Error(`Expected Error here but got ${typeof e}:${e}`)
}
await client.rejectRpc(rpc, e).catch((e) => {
error(`rpc ${rpc.id} failed to reject with ${e}`)
})
}
})
}
const unsub = await client.onNewRpc(run)
await client.resetRpcs()
await client.resetBackups()
const rpcs = await client.incompleteRpcs()
rpcs.forEach(run)
const shutdown = () => {
unsub()
}
const ajv = new Ajv()
const registerCommand = <
TPayload extends JsonObject,
TResult extends JsonObject
>(
commandName: RpcCommands,
schema: JSONSchemaType<TPayload>,
runner: RpcRunner<TPayload, TResult>
) => {
if (jobHandlers[commandName]) {
throw new Error(`${commandName} job handler already registered.`)
}
jobHandlers[commandName] = {
validate: ajv.compile(schema),
run: runner,
}
}
return {
registerCommand,
shutdown,
}
}

View File

@ -37,6 +37,7 @@
"pretty-bytes": "^6.0.0",
"random-word-slugs": "^0.1.6",
"sass": "^1.54.9",
"svelte-highlight": "^6.2.1"
"svelte-highlight": "^6.2.1",
"type-fest": "^3.2.0"
}
}

View File

@ -1,22 +1,21 @@
import { createGenericSyncEvent } from '$util/events'
import {
assertExists,
createRpcHelper,
createWatchHelper,
JobCommands,
JobStatus,
type BackupRecord,
type BackupRecordId,
type InstanceBackupJobPayload,
type InstanceBackupJobRecord,
RpcCommands,
type BackupFields,
type BackupInstancePayload,
type BackupInstanceResult,
type CreateInstancePayload,
type CreateInstanceResult,
type InstanceFields,
type InstanceId,
type InstanceRestoreJobPayload,
type InstancesRecord,
type InstancesRecord_New,
type JobRecord,
type JobRecord_In,
type Logger,
type PromiseHelper,
type UserRecord
type RestoreInstancePayload,
type RestoreInstanceResult,
type UserFields
} from '@pockethost/common'
import { keys, map } from '@s-libs/micro-dash'
import PocketBase, {
@ -33,7 +32,7 @@ export type AuthChangeHandler = (user: BaseAuthStore) => void
export type AuthToken = string
export type AuthStoreProps = {
token: AuthToken
model: UserRecord | null
model: UserFields | null
isValid: boolean
}
@ -112,28 +111,34 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
const watchHelper = createWatchHelper({ client, promiseHelper, logger })
const { watchById, watchAllById } = watchHelper
const rpcMixin = createRpcHelper({ client, watchHelper, promiseHelper, logger })
const { mkRpc } = rpcMixin
const createInstance = safeCatch(
`createInstance`,
(payload: InstancesRecord_New): Promise<InstancesRecord> => {
return client.collection('instances').create<InstancesRecord>(payload)
}
const createInstance = mkRpc<CreateInstancePayload, CreateInstanceResult>(
RpcCommands.CreateInstance
)
const createInstanceBackupJob = mkRpc<BackupInstancePayload, BackupInstanceResult>(
RpcCommands.BackupInstance
)
const createInstanceRestoreJob = mkRpc<RestoreInstancePayload, RestoreInstanceResult>(
RpcCommands.RestoreInstance
)
const getInstanceById = safeCatch(
`getInstanceById`,
(id: InstanceId): Promise<InstancesRecord | undefined> =>
client.collection('instances').getOne<InstancesRecord>(id)
(id: InstanceId): Promise<InstanceFields | undefined> =>
client.collection('instances').getOne<InstanceFields>(id)
)
const watchInstanceById = async (
id: InstanceId,
cb: (data: RecordSubscription<InstancesRecord>) => void
cb: (data: RecordSubscription<InstanceFields>) => void
): Promise<UnsubscribeFunc> => watchById('instances', id, cb)
const watchBackupsByInstanceId = async (
id: InstanceId,
cb: (data: RecordSubscription<BackupRecord>) => void
cb: (data: RecordSubscription<BackupFields>) => void
): Promise<UnsubscribeFunc> => watchAllById('backups', 'instanceId', id, cb)
const getAllInstancesById = safeCatch(`getAllInstancesById`, async () =>
@ -226,44 +231,6 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
})
}
const createInstanceBackupJob = safeCatch(
`createInstanceBackupJob`,
async (instanceId: InstanceId) => {
const _user = user()
assertExists(_user, `Expected user to exist here`)
const { id: userId } = _user
const job: JobRecord_In<InstanceBackupJobPayload> = {
userId,
status: JobStatus.New,
payload: {
cmd: JobCommands.BackupInstance,
instanceId
}
}
const rec = await client.collection('jobs').create<InstanceBackupJobRecord>(job)
return rec
}
)
const createInstanceRestoreJob = safeCatch(
`createInstanceRestoreJob`,
async (backupId: BackupRecordId) => {
const _user = user()
assertExists(_user, `Expected user to exist here`)
const { id: userId } = _user
const job: JobRecord_In<InstanceRestoreJobPayload> = {
userId,
status: JobStatus.New,
payload: {
cmd: JobCommands.RestoreInstance,
backupId
}
}
const rec = await client.collection('jobs').create<JobRecord<InstanceRestoreJobPayload>>(job)
return rec
}
)
return {
getAuthStoreProps,
parseError,

View File

@ -4,9 +4,8 @@
import { createCleanupManagerSync } from '$util/CleanupManager'
import {
BackupStatus,
type BackupRecord,
type BackupRecordId,
type InstancesRecord,
type BackupFields,
type InstanceFields,
type RecordId
} from '@pockethost/common'
import { reduce, sortBy } from '@s-libs/micro-dash'
@ -15,10 +14,10 @@
import { onDestroy, onMount } from 'svelte'
import { writable } from 'svelte/store'
export let instance: InstancesRecord
export let instance: InstanceFields
const cm = createCleanupManagerSync()
const backups = writable<BackupRecord[]>([])
const backups = writable<BackupFields[]>([])
let isBackingUp = false
onMount(async () => {
const { watchBackupsByInstanceId } = client()
@ -31,7 +30,7 @@
c[b.id] = b
return c
},
{} as { [_: RecordId]: BackupRecord }
{} as { [_: RecordId]: BackupFields }
)
_backups[record.id] = record
@ -50,11 +49,9 @@
const startBackup = () => {
const { createInstanceBackupJob } = client()
createInstanceBackupJob(instance.id)
}
const restoreBackup = (backupId: BackupRecordId) => {
client().createInstanceRestoreJob(backupId)
createInstanceBackupJob({
instanceId: instance.id
})
}
</script>

View File

@ -2,9 +2,9 @@
import CodeSample from '$components/CodeSample.svelte'
import { PUBLIC_PB_PROTOCOL } from '$env/static/public'
import { PUBLIC_PB_DOMAIN } from '$src/env'
import type { InstancesRecord } from '@pockethost/common'
import type { InstanceFields } from '@pockethost/common'
export let instance: InstancesRecord
export let instance: InstanceFields
const { subdomain, status, platform, version } = instance
const url = `${PUBLIC_PB_PROTOCOL}://${subdomain}.${PUBLIC_PB_DOMAIN}`

View File

@ -2,10 +2,10 @@
import ProvisioningStatus from '$components/ProvisioningStatus.svelte'
import { PUBLIC_PB_PROTOCOL } from '$env/static/public'
import { PUBLIC_PB_DOMAIN } from '$src/env'
import type { InstancesRecord } from '@pockethost/common'
import type { InstanceFields } from '@pockethost/common'
import { humanVersion } from '@pockethost/common'
export let instance: InstancesRecord
export let instance: InstanceFields
const { subdomain, status, platform, version } = instance
const url = `${PUBLIC_PB_PROTOCOL}://${subdomain}.${PUBLIC_PB_DOMAIN}`

View File

@ -1,7 +1,7 @@
<script lang="ts">
import type { InstancesRecord } from '@pockethost/common'
import type { InstanceFields } from '@pockethost/common'
export let instance: InstancesRecord
export let instance: InstanceFields
</script>
<div class="py-4">

View File

@ -1,4 +1,4 @@
import type { InstancesRecord } from '@pockethost/common'
import type { InstanceFields } from '@pockethost/common'
import { writable } from 'svelte/store'
export const instance = writable<InstancesRecord | undefined>()
export const instance = writable<InstanceFields | undefined>()

View File

@ -11,18 +11,23 @@
let rotationCounter: number = 0
let isFormButtonDisabled: boolean = true
$: isFormButtonDisabled = instanceName.length === 0
$: isFormButtonDisabled = instanceName.length === 0 || isSubmitting
const handleInstanceNameRegeneration = () => {
rotationCounter = rotationCounter + 180
instanceName = generateSlug(2)
}
let isSubmitting = false
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault()
isSubmitting = true
formError = ''
await handleCreateNewInstance(instanceName, (error) => {
formError = error
}).finally(() => {
isSubmitting = false
})
}
</script>
@ -66,7 +71,8 @@
{/if}
<div class="text-center">
<a href="/dashboard" class="btn btn-light">Cancel</a>
<button href="/dashboard" class="btn btn-light" disabled={isFormButtonDisabled}>Cancel</button
>
<button type="submit" class="btn btn-primary" disabled={isFormButtonDisabled}>
Create <i class="bi bi-arrow-right-short" />

View File

@ -7,17 +7,17 @@
import { client } from '$src/pocketbase'
import { createCleanupManagerSync } from '$util/CleanupManager'
import { error } from '$util/logger'
import { humanVersion, type InstanceRecordById, type InstancesRecord } from '@pockethost/common'
import { humanVersion, type InstanceFields, type InstanceRecordsById } from '@pockethost/common'
import { forEach, values } from '@s-libs/micro-dash'
import { onDestroy, onMount } from 'svelte'
import { fade } from 'svelte/transition'
let apps: InstanceRecordById = {}
let apps: InstanceRecordsById = {}
// This will update when the `apps` value changes
$: isFirstApplication = values(apps).length === 0
let appsArray: InstancesRecord[]
let appsArray: InstanceFields[]
$: {
appsArray = values(apps)
// Tooltips must be manually initialized
@ -31,7 +31,7 @@
}
const cm = createCleanupManagerSync()
let _touch = 0 // This is a fake var because without it the watcher callback will not update UI when the apps object changes
const _update = (_apps: InstanceRecordById) => {
const _update = (_apps: InstanceRecordsById) => {
apps = _apps
_touch++
}

View File

@ -1,19 +1,18 @@
import { goto } from '$app/navigation'
import { client } from '$src/pocketbase'
import { InstanceStatus, LATEST_PLATFORM, USE_LATEST_VERSION } from '@pockethost/common'
import { dbg, error, warn } from './logger'
export type FormErrorHandler = (value: string) => void
export const handleFormError = (error: any, setError?: FormErrorHandler) => {
export const handleFormError = (e: Error, setError?: FormErrorHandler) => {
const { parseError } = client()
error(`Form error: ${error}`, { error })
error(`Form error: ${e}`, { error: e })
if (setError) {
const message = parseError(error)[0]
const message = parseError(e)[0]
setError(message)
} else {
throw error
throw e
}
}
@ -40,7 +39,10 @@ export const handleLogin = async (
if (shouldRedirect) {
await goto('/dashboard')
}
} catch (error: any) {
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`Expected Error type here, but got ${typeof error}:${error}`)
}
handleFormError(error, setError)
}
}
@ -151,14 +153,10 @@ export const handleCreateNewInstance = async (
// Create a new instance using the generated name
const record = await createInstance({
subdomain: instanceName,
uid: id,
status: InstanceStatus.Idle,
platform: LATEST_PLATFORM,
version: USE_LATEST_VERSION
subdomain: instanceName
})
await goto(`/app/instances/${record.id}`)
await goto(`/app/instances/${record.instance.id}`)
} catch (error: any) {
handleFormError(error, setError)
}

View File

@ -1041,6 +1041,16 @@ aggregate-error@^3.0.0:
clean-stack "^2.0.0"
indent-string "^4.0.0"
ajv@^8.11.2:
version "8.11.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.2.tgz#aecb20b50607acf2569b6382167b65a96008bb78"
integrity sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==
dependencies:
fast-deep-equal "^3.1.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
uri-js "^4.2.2"
ansi-escapes@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b"
@ -1828,6 +1838,11 @@ eventsource@^2.0.2:
resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508"
integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==
fast-deep-equal@^3.1.1:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^3.2.7:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@ -2344,6 +2359,11 @@ json-parse-even-better-errors@^2.3.0:
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
json5@^2.2.0, json5@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
@ -3185,6 +3205,11 @@ prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
punycode@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@ -3238,6 +3263,11 @@ require-directory@^2.1.1:
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
@ -3810,7 +3840,7 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
type-fest@^3.1.0:
type-fest@^3.1.0, type-fest@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.2.0.tgz#2c8b49e775d9e314a73ea6fcee0b2e8549d5f886"
integrity sha512-Il3wdLRzWvbAEtocgxGQA9YOoRVeVUGOMBtel5LdEpNeEAol6GJTLw8GbX6Z8EIMfvfhoOXs2bwOijtAZdK5og==
@ -3854,6 +3884,13 @@ update-browserslist-db@^1.0.9:
escalade "^3.1.1"
picocolors "^1.0.0"
uri-js@^4.2.2:
version "4.4.1"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
dependencies:
punycode "^2.1.0"
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"