mirror of
https://github.com/pockethost/pockethost.git
synced 2025-03-30 15:08:30 +00:00
refactor: rpc
This commit is contained in:
parent
d8f030a3e2
commit
4e65a7b948
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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?.()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
@ -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 }
|
18
packages/common/src/schema/Rpc/BackupInstance.ts
Normal file
18
packages/common/src/schema/Rpc/BackupInstance.ts
Normal 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 }
|
21
packages/common/src/schema/Rpc/CreateInstance.ts
Normal file
21
packages/common/src/schema/Rpc/CreateInstance.ts
Normal 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,
|
||||
}
|
20
packages/common/src/schema/Rpc/RestoreInstance.ts
Normal file
20
packages/common/src/schema/Rpc/RestoreInstance.ts
Normal 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,
|
||||
}
|
14
packages/common/src/schema/Rpc/index.ts
Normal file
14
packages/common/src/schema/Rpc/index.ts
Normal 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'
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
@ -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,
|
||||
|
83
packages/daemon/src/db/RpcHelper.ts
Normal file
83
packages/daemon/src/db/RpcHelper.ts
Normal 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,
|
||||
}
|
||||
}
|
@ -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: {},
|
||||
|
@ -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()
|
||||
})
|
||||
})()
|
||||
|
@ -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({
|
||||
|
@ -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 })
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
125
packages/daemon/src/services/RpcService.ts
Normal file
125
packages/daemon/src/services/RpcService.ts
Normal 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,
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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}`
|
||||
|
@ -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}`
|
||||
|
@ -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">
|
||||
|
@ -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>()
|
||||
|
@ -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" />
|
||||
|
@ -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++
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
39
yarn.lock
39
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user