Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -30,6 +30,7 @@ import { startScheduler } from './services/scheduler.js'
|
||||
import { authPlugin } from './plugins/auth.js'
|
||||
import { spacePlugin } from './plugins/space.js'
|
||||
import { spacesRoutes } from './routes/spaces.js'
|
||||
import { portalUsersRoutes } from './routes/portal-users.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
await app.register(spacesRoutes)
|
||||
await app.register(portalUsersRoutes)
|
||||
await app.register(dataRoutes)
|
||||
await app.register(vpsRoutes)
|
||||
await app.register(providersRoutes)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { existsSync } from 'node:fs'
|
||||
import {
|
||||
getDb,
|
||||
getDbPath,
|
||||
readDatabaseFileBuffer,
|
||||
reloadDatabaseFromBuffer,
|
||||
schema,
|
||||
} from '@cfdm/db'
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
import { spacesRepository } from '@cfdm/db/repositories/spaces'
|
||||
|
||||
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
|
||||
import {
|
||||
FULL_BACKUP_VERSION,
|
||||
getFullBackupSnapshot,
|
||||
importFullBackup,
|
||||
type FullBackupPayload,
|
||||
} from '../services/backup-full.js'
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
|
||||
const BACKUP_VERSION = 1
|
||||
|
||||
/** Лимит тела для импорта бэкапа (Fastify default = 1 MiB → 413). */
|
||||
function backupBodyLimitBytes(): number {
|
||||
const raw = process.env.BACKUP_BODY_LIMIT_BYTES
|
||||
@@ -37,18 +38,7 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
)
|
||||
|
||||
app.get('/api/backup/json', async (_req, reply) => {
|
||||
const syncLog = getDb()
|
||||
.select()
|
||||
.from(schema.syncLog)
|
||||
.orderBy(desc(schema.syncLog.startedAt))
|
||||
.limit(500)
|
||||
.all()
|
||||
const snapshot = {
|
||||
backupVersion: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
...getSnapshot(),
|
||||
syncLog,
|
||||
}
|
||||
const snapshot = getFullBackupSnapshot()
|
||||
reply.header('Content-Type', 'application/json; charset=utf-8')
|
||||
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
|
||||
return reply.send(JSON.stringify(snapshot, null, 2))
|
||||
@@ -74,7 +64,14 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
|
||||
}
|
||||
try {
|
||||
importJsonSnapshot(payload as BackupPayload)
|
||||
const data = payload as FullBackupPayload
|
||||
if (data.tables && typeof data.tables === 'object') {
|
||||
importFullBackup(data)
|
||||
} else {
|
||||
importJsonSnapshot(payload as BackupPayload)
|
||||
}
|
||||
void FULL_BACKUP_VERSION
|
||||
spacesRepository.getMain()
|
||||
restartScheduler()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
@@ -95,6 +92,7 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
try {
|
||||
reloadDatabaseFromBuffer(buf)
|
||||
spacesRepository.getMain()
|
||||
restartScheduler()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
/**
|
||||
* Proxy portal directory users so the SPA searches by name/email
|
||||
* without calling auth-portal CORS from the browser.
|
||||
*/
|
||||
export const portalUsersRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get<{ Querystring: { q?: string } }>('/api/portal-users', async (req, reply) => {
|
||||
const portalUrl = app.authConfig?.portalUrl
|
||||
if (!portalUrl) {
|
||||
return reply.code(503).send({
|
||||
error: { code: 'UNAVAILABLE', message: 'AUTH_PORTAL_URL не настроен' },
|
||||
})
|
||||
}
|
||||
|
||||
const auth = req.headers.authorization
|
||||
if (!auth) {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
|
||||
const q = String(req.query.q ?? '').trim()
|
||||
const url = new URL('/api/v1/directory/users', portalUrl)
|
||||
if (q) url.searchParams.set('q', q)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: auth, Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
return reply.code(res.status).send({
|
||||
error: {
|
||||
code: 'PORTAL_ERROR',
|
||||
message: text || `Portal HTTP ${res.status}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
const data = (await res.json()) as { id: string; email: string; name: string }[]
|
||||
return Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
code: 'BAD_GATEWAY',
|
||||
message: err instanceof Error ? err.message : 'Ошибка portal',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -103,4 +103,112 @@ describe('spaces API', () => {
|
||||
expect(body.permission).toBe('read')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('soft-delete → restore; soft → purge; purge active/main → 400', async () => {
|
||||
const personal = spacesRepository.create({
|
||||
id: 'space-user-trash',
|
||||
name: 'Trashable',
|
||||
slug: 'trashable',
|
||||
kind: 'personal',
|
||||
ownerUserId: 'u-trash',
|
||||
})
|
||||
runWithSpace(personal.id, () =>
|
||||
vpsRepository.create({
|
||||
ip: '9.9.9.9',
|
||||
providerId: 'prov-1',
|
||||
providerAccountId: 'acc-1',
|
||||
status: 'active',
|
||||
}),
|
||||
)
|
||||
|
||||
const app = await buildApp()
|
||||
|
||||
const soft = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/spaces/${personal.id}`,
|
||||
})
|
||||
expect(soft.statusCode).toBe(200)
|
||||
expect((soft.json() as { deletedAt: string }).deletedAt).toBeTruthy()
|
||||
|
||||
const activeList = await app.inject({ method: 'GET', url: '/api/spaces' })
|
||||
expect(
|
||||
(activeList.json() as { id: string }[]).some((s) => s.id === personal.id),
|
||||
).toBe(false)
|
||||
|
||||
const trashList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/spaces?deleted=1',
|
||||
})
|
||||
expect(
|
||||
(trashList.json() as { id: string }[]).some((s) => s.id === personal.id),
|
||||
).toBe(true)
|
||||
|
||||
const restore = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/spaces/${personal.id}/restore`,
|
||||
})
|
||||
expect(restore.statusCode).toBe(200)
|
||||
expect((restore.json() as { deletedAt: string | null }).deletedAt).toBeFalsy()
|
||||
|
||||
// soft again then purge
|
||||
await app.inject({ method: 'DELETE', url: `/api/spaces/${personal.id}` })
|
||||
|
||||
const purgeActive = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/spaces/${MAIN_SPACE_ID}/purge`,
|
||||
})
|
||||
expect(purgeActive.statusCode).toBe(400)
|
||||
|
||||
const purgeBeforeTrash = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/spaces/${personal.id}`,
|
||||
})
|
||||
// already soft-deleted — soft again returns existing
|
||||
expect(purgeBeforeTrash.statusCode).toBe(200)
|
||||
|
||||
const stillActive = spacesRepository.create({
|
||||
id: 'space-user-active-purge',
|
||||
name: 'Active',
|
||||
slug: 'active-purge',
|
||||
kind: 'personal',
|
||||
ownerUserId: 'u-a',
|
||||
})
|
||||
const purgeActiveSpace = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/spaces/${stillActive.id}/purge`,
|
||||
})
|
||||
expect(purgeActiveSpace.statusCode).toBe(400)
|
||||
|
||||
const purged = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/spaces/${personal.id}/purge`,
|
||||
})
|
||||
expect(purged.statusCode).toBe(204)
|
||||
expect(spacesRepository.getAny(personal.id)).toBeUndefined()
|
||||
expect(runWithSpace(personal.id, () => vpsRepository.list()).length).toBe(0)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('transfers ownership', async () => {
|
||||
const personal = spacesRepository.create({
|
||||
id: 'space-user-xfer',
|
||||
name: 'Xfer',
|
||||
slug: 'xfer',
|
||||
kind: 'personal',
|
||||
ownerUserId: 'owner-a',
|
||||
})
|
||||
const app = await buildApp()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/spaces/${personal.id}/transfer-ownership`,
|
||||
payload: { newOwnerUserId: 'owner-b' },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { ownerUserId: string }
|
||||
expect(body.ownerUserId).toBe('owner-b')
|
||||
expect(spacesRepository.getMember(personal.id, 'owner-a')?.role).toBe('admin')
|
||||
expect(spacesRepository.getMember(personal.id, 'owner-b')?.role).toBe('owner')
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,13 +18,29 @@ function isSpacesAdmin(request: { authUser?: { isAdmin?: boolean; permissions: s
|
||||
return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin')
|
||||
}
|
||||
|
||||
function canManageDeletedSpace(
|
||||
space: { ownerUserId: string | null; id: string },
|
||||
userId: string | undefined,
|
||||
admin: boolean,
|
||||
): boolean {
|
||||
if (admin) return true
|
||||
if (!userId) return true
|
||||
if (space.ownerUserId === userId) return true
|
||||
const member = spacesRepository.getMember(space.id, userId)
|
||||
return member?.role === 'owner'
|
||||
}
|
||||
|
||||
export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/spaces', async (req) => {
|
||||
app.get<{ Querystring: { deleted?: string } }>('/api/spaces', async (req) => {
|
||||
const user = req.authUser
|
||||
const deletedOnly = req.query.deleted === '1' || req.query.deleted === 'true'
|
||||
if (!user) {
|
||||
if (deletedOnly) {
|
||||
return spacesRepository.listDeleted().map((s) => ({ ...s, role: 'owner' }))
|
||||
}
|
||||
return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' }))
|
||||
}
|
||||
return spacesRepository.listForUser(user.id, isSpacesAdmin(req))
|
||||
return spacesRepository.listForUser(user.id, isSpacesAdmin(req), { deletedOnly })
|
||||
})
|
||||
|
||||
app.post('/api/spaces', async (req, reply) => {
|
||||
@@ -65,8 +81,114 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { ...space, role: member?.role ?? 'viewer' }
|
||||
})
|
||||
|
||||
/** Soft-delete → корзина */
|
||||
app.delete<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
|
||||
const space = spacesRepository.getAny(req.params.id)
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
if (space.kind === 'main' || space.id === MAIN_SPACE_ID) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Нельзя удалить основное пространство' },
|
||||
})
|
||||
}
|
||||
if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) {
|
||||
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Только владелец' } })
|
||||
}
|
||||
const updated = spacesRepository.softDelete(req.params.id)
|
||||
if (!updated) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Не удалось удалить' },
|
||||
})
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/spaces/:id/restore', async (req, reply) => {
|
||||
const space = spacesRepository.getAny(req.params.id)
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
if (!space.deletedAt) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Пространство не в корзине' },
|
||||
})
|
||||
}
|
||||
if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) {
|
||||
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
|
||||
}
|
||||
return spacesRepository.restore(req.params.id)
|
||||
})
|
||||
|
||||
/** Hard purge — only soft-deleted */
|
||||
app.delete<{ Params: { id: string } }>('/api/spaces/:id/purge', async (req, reply) => {
|
||||
const space = spacesRepository.getAny(req.params.id)
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
if (space.kind === 'main' || space.id === MAIN_SPACE_ID) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Нельзя удалить основное пространство' },
|
||||
})
|
||||
}
|
||||
if (!space.deletedAt) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION',
|
||||
message: 'Сначала переместите пространство в корзину',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) {
|
||||
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
|
||||
}
|
||||
const ok = spacesRepository.purge(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Не удалось удалить навсегда' },
|
||||
})
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/spaces/:id/transfer-ownership',
|
||||
async (req, reply) => {
|
||||
const space = spacesRepository.get(req.params.id)
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
const user = req.authUser
|
||||
const admin = isSpacesAdmin(req)
|
||||
if (user) {
|
||||
const member = spacesRepository.getMember(space.id, user.id)
|
||||
if (!admin && member?.role !== 'owner') {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Только владелец может передать владение',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
const body = req.body as { newOwnerUserId?: string }
|
||||
const newOwnerUserId = String(body.newOwnerUserId ?? '').trim()
|
||||
if (!newOwnerUserId) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'newOwnerUserId обязателен' },
|
||||
})
|
||||
}
|
||||
const updated = spacesRepository.transferOwnership(req.params.id, newOwnerUserId)
|
||||
if (!updated) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Не удалось передать владение' },
|
||||
})
|
||||
}
|
||||
return updated
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
|
||||
// Temporarily set space for role check
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const body = req.body as { name?: string; slug?: string }
|
||||
@@ -159,7 +281,6 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
)
|
||||
|
||||
/** Share VPS from current ownership space to target space */
|
||||
app.post<{ Params: { id: string; vpsId: string } }>(
|
||||
'/api/spaces/:id/vps/:vpsId/share',
|
||||
async (req, reply) => {
|
||||
@@ -202,7 +323,10 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const grant = vpsGrantsRepository.get(req.params.grantId)
|
||||
if (!grant || (grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)) {
|
||||
if (
|
||||
!grant ||
|
||||
(grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)
|
||||
) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
vpsGrantsRepository.delete(req.params.grantId)
|
||||
@@ -210,7 +334,6 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
)
|
||||
|
||||
/** Assign (move) VPS to target space */
|
||||
app.post<{ Params: { id: string; vpsId: string } }>(
|
||||
'/api/spaces/:id/vps/:vpsId/assign',
|
||||
async (req, reply) => {
|
||||
@@ -235,8 +358,7 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
}
|
||||
vpsGrantsRepository.deleteByVps(req.params.vpsId)
|
||||
const moved = vpsRepository.assignToSpace(req.params.vpsId, toSpaceId)
|
||||
return moved
|
||||
return vpsRepository.assignToSpace(req.params.vpsId, toSpaceId)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getDb, getSqlite, schema } from '@cfdm/db'
|
||||
import {
|
||||
spacesRepository,
|
||||
vpsGrantsRepository,
|
||||
} from '@cfdm/db/repositories/spaces'
|
||||
|
||||
const TABLE_ORDER_DELETE = [
|
||||
'vps_grants',
|
||||
'notification_log',
|
||||
'notification_state',
|
||||
'vps_health_checks',
|
||||
'audit_log',
|
||||
'sync_log',
|
||||
'active_tariffs',
|
||||
'tariff_sync_options',
|
||||
'topology_diagrams',
|
||||
'vps_domains',
|
||||
'balance_ledger',
|
||||
'payments',
|
||||
'vps',
|
||||
'provider_accounts',
|
||||
'server_projects',
|
||||
'providers',
|
||||
'settings',
|
||||
'space_members',
|
||||
'spaces',
|
||||
] as const
|
||||
|
||||
const TABLE_ORDER_INSERT = [...TABLE_ORDER_DELETE].reverse()
|
||||
|
||||
export const FULL_BACKUP_VERSION = 2
|
||||
|
||||
export type FullBackupPayload = {
|
||||
backupVersion?: number
|
||||
exportedAt?: string
|
||||
tables?: Record<string, Record<string, unknown>[]>
|
||||
/** Legacy v1 fields — ignored when tables present */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function selectAll(table: string): Record<string, unknown>[] {
|
||||
const sqlite = getSqlite()
|
||||
try {
|
||||
return sqlite.prepare(`SELECT * FROM ${table}`).all() as Record<string, unknown>[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Full multi-space dump for backup export. */
|
||||
export function getFullBackupSnapshot(): FullBackupPayload {
|
||||
const tables: Record<string, Record<string, unknown>[]> = {}
|
||||
for (const table of TABLE_ORDER_INSERT) {
|
||||
tables[table] = selectAll(table)
|
||||
}
|
||||
return {
|
||||
backupVersion: FULL_BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
return `"${name.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function insertRows(table: string, rows: Record<string, unknown>[]): void {
|
||||
if (!rows.length) return
|
||||
const sqlite = getSqlite()
|
||||
const cols = Object.keys(rows[0]!)
|
||||
const placeholders = cols.map(() => '?').join(', ')
|
||||
const colList = cols.map(quoteIdent).join(', ')
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT INTO ${table} (${colList}) VALUES (${placeholders})`,
|
||||
)
|
||||
for (const row of rows) {
|
||||
stmt.run(...cols.map((c) => row[c] ?? null))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full overwrite import (v2 tables dump).
|
||||
* Falls back to legacy importJsonSnapshot when `tables` missing.
|
||||
*/
|
||||
export function importFullBackup(data: FullBackupPayload): void {
|
||||
const tables = data.tables
|
||||
if (!tables || typeof tables !== 'object') {
|
||||
throw new Error('FULL_BACKUP_REQUIRED')
|
||||
}
|
||||
|
||||
const sqlite = getSqlite()
|
||||
sqlite.exec('BEGIN')
|
||||
try {
|
||||
sqlite.pragma('foreign_keys = OFF')
|
||||
for (const table of TABLE_ORDER_DELETE) {
|
||||
try {
|
||||
sqlite.exec(`DELETE FROM ${table}`)
|
||||
} catch {
|
||||
/* missing table */
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of TABLE_ORDER_INSERT) {
|
||||
const rows = Array.isArray(tables[table]) ? tables[table]! : []
|
||||
insertRows(table, rows)
|
||||
}
|
||||
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
sqlite.exec('COMMIT')
|
||||
} catch (err) {
|
||||
sqlite.exec('ROLLBACK')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
throw err
|
||||
}
|
||||
|
||||
// Ensure main space exists after import
|
||||
spacesRepository.getMain()
|
||||
}
|
||||
|
||||
export function listAllSpaceMembers(): typeof schema.spaceMembers.$inferSelect[] {
|
||||
return getDb().select().from(schema.spaceMembers).all()
|
||||
}
|
||||
|
||||
export function listAllSpaces() {
|
||||
return spacesRepository.listAll({ includeDeleted: true })
|
||||
}
|
||||
|
||||
export function listAllGrants() {
|
||||
return vpsGrantsRepository.listAll()
|
||||
}
|
||||
Reference in New Issue
Block a user