feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s

Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 15:42:54 +07:00
co-authored by Cursor
parent f26b2c8777
commit e360efb885
47 changed files with 2675 additions and 237 deletions
+7
View File
@@ -54,6 +54,13 @@ export function reloadDatabaseFromBuffer(buffer: Buffer): void {
}
export { schema }
export {
MAIN_SPACE_ID,
getCurrentSpaceId,
runWithSpace,
runWithSpaceAsync,
settingsIdForSpace,
} from './space-context.js'
export {
consolidateAllProviderApiSources,
consolidateProviderApiFromAccounts,
+14 -1
View File
@@ -1,12 +1,14 @@
import { randomUUID } from 'node:crypto'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export interface AuditEntryInput {
entity: string
entityId: string
action: 'create' | 'update' | 'delete'
diff?: Record<string, unknown>
actorUserId?: string | null
}
function parseDiff(row: { diff: string | null }) {
@@ -24,19 +26,23 @@ export const auditLogRepository = {
.insert(schema.auditLog)
.values({
id: `audit-${randomUUID()}`,
spaceId: getCurrentSpaceId(),
entity: input.entity,
entityId: input.entityId,
action: input.action,
diff: input.diff ? JSON.stringify(input.diff) : null,
actorUserId: input.actorUserId ?? null,
createdAt: new Date().toISOString(),
})
.run()
},
list(limit = 100) {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.auditLog)
.where(eq(schema.auditLog.spaceId, spaceId))
.orderBy(desc(schema.auditLog.createdAt))
.limit(Math.min(500, Math.max(1, limit)))
.all()
@@ -44,10 +50,17 @@ export const auditLogRepository = {
},
listForEntity(entity: string, entityId: string, limit = 50) {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.auditLog)
.where(and(eq(schema.auditLog.entity, entity), eq(schema.auditLog.entityId, entityId)))
.where(
and(
eq(schema.auditLog.spaceId, spaceId),
eq(schema.auditLog.entity, entity),
eq(schema.auditLog.entityId, entityId),
),
)
.orderBy(desc(schema.auditLog.createdAt))
.limit(limit)
.all()
+27 -7
View File
@@ -1,5 +1,6 @@
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
type Row = typeof schema.balanceLedger.$inferSelect
@@ -24,14 +25,28 @@ function normalize(input: Partial<Row>) {
export const balanceLedgerRepository = {
list(): Row[] {
return getDb().select().from(schema.balanceLedger).orderBy(desc(schema.balanceLedger.date)).all()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.balanceLedger)
.where(eq(schema.balanceLedger.spaceId, spaceId))
.orderBy(desc(schema.balanceLedger.date))
.all()
},
get(id: string): Row | undefined {
return getDb().select().from(schema.balanceLedger).where(eq(schema.balanceLedger.id, id)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.balanceLedger)
.where(and(eq(schema.balanceLedger.id, id), eq(schema.balanceLedger.spaceId, spaceId)))
.get()
},
create(input: Insert, id?: string): Row {
const finalId = id ?? input.id ?? generateId('ledger')
getDb().insert(schema.balanceLedger).values({ id: finalId, ...normalize(input) }).run()
getDb()
.insert(schema.balanceLedger)
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
update(id: string, input: Partial<Row>): Row | undefined {
@@ -39,13 +54,18 @@ export const balanceLedgerRepository = {
if (!existing) return undefined
getDb()
.update(schema.balanceLedger)
.set(normalize({ ...existing, ...input }))
.where(eq(schema.balanceLedger.id, id))
.set({ ...normalize({ ...existing, ...input }), spaceId: existing.spaceId })
.where(and(eq(schema.balanceLedger.id, id), eq(schema.balanceLedger.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.balanceLedger).where(eq(schema.balanceLedger.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.balanceLedger)
.where(and(eq(schema.balanceLedger.id, id), eq(schema.balanceLedger.spaceId, existing.spaceId)))
.run()
return r.changes > 0
},
}
+27 -3
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export type NotificationChannel = 'telegram' | 'webhook'
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
@@ -43,9 +44,11 @@ function toLogDto(row: typeof schema.notificationLog.$inferSelect): Notification
export const notificationRepository = {
listRecent(limit = 50): NotificationLogRow[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.notificationLog)
.where(eq(schema.notificationLog.spaceId, spaceId))
.orderBy(desc(schema.notificationLog.createdAt))
.limit(Math.min(200, Math.max(1, limit)))
.all()
@@ -64,6 +67,7 @@ export const notificationRepository = {
.insert(schema.notificationLog)
.values({
id: `nlog-${randomUUID()}`,
spaceId: getCurrentSpaceId(),
event: entry.event,
channel: entry.channel,
status: entry.status,
@@ -76,20 +80,40 @@ export const notificationRepository = {
},
getState(key: string) {
return getDb().select().from(schema.notificationState).where(eq(schema.notificationState.key, key)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.notificationState)
.where(
and(
eq(schema.notificationState.key, key),
eq(schema.notificationState.spaceId, spaceId),
),
)
.get()
},
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
const db = getDb()
const spaceId = getCurrentSpaceId()
const existing = this.getState(key)
const values = {
key,
spaceId,
lastFingerprint: patch.lastFingerprint ?? existing?.lastFingerprint ?? null,
lastSentAt: patch.lastSentAt ?? existing?.lastSentAt ?? null,
lastStatus: patch.lastStatus ?? existing?.lastStatus ?? null,
}
if (existing) {
db.update(schema.notificationState).set(values).where(eq(schema.notificationState.key, key)).run()
db.update(schema.notificationState)
.set(values)
.where(
and(
eq(schema.notificationState.key, key),
eq(schema.notificationState.spaceId, spaceId),
),
)
.run()
} else {
db.insert(schema.notificationState).values(values).run()
}
+27 -7
View File
@@ -1,5 +1,6 @@
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
type Row = typeof schema.payments.$inferSelect
@@ -19,14 +20,28 @@ function normalize(input: Partial<Row>) {
export const paymentsRepository = {
list(): Row[] {
return getDb().select().from(schema.payments).orderBy(desc(schema.payments.date)).all()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.payments)
.where(eq(schema.payments.spaceId, spaceId))
.orderBy(desc(schema.payments.date))
.all()
},
get(id: string): Row | undefined {
return getDb().select().from(schema.payments).where(eq(schema.payments.id, id)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.payments)
.where(and(eq(schema.payments.id, id), eq(schema.payments.spaceId, spaceId)))
.get()
},
create(input: Insert, id?: string): Row {
const finalId = id ?? input.id ?? generateId('pay')
getDb().insert(schema.payments).values({ id: finalId, ...normalize(input) }).run()
getDb()
.insert(schema.payments)
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
update(id: string, input: Partial<Row>): Row | undefined {
@@ -34,13 +49,18 @@ export const paymentsRepository = {
if (!existing) return undefined
getDb()
.update(schema.payments)
.set(normalize({ ...existing, ...input }))
.where(eq(schema.payments.id, id))
.set({ ...normalize({ ...existing, ...input }), spaceId: existing.spaceId })
.where(and(eq(schema.payments.id, id), eq(schema.payments.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.payments).where(eq(schema.payments.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.payments)
.where(and(eq(schema.payments.id, id), eq(schema.payments.spaceId, existing.spaceId)))
.run()
return r.changes > 0
},
}
+50 -7
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { eq, like, asc, sql } from 'drizzle-orm'
import { and, eq, like, asc, sql } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export function normalizeProjectNameInput(name: unknown): string {
if (name == null) return ''
@@ -12,10 +13,16 @@ export function findProjectByNameCaseInsensitive(
): (typeof schema.serverProjects.$inferSelect) | undefined {
const n = normalizeProjectNameInput(name)
if (!n) return undefined
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(sql`LOWER(${schema.serverProjects.name})`, n.toLowerCase()))
.where(
and(
eq(schema.serverProjects.spaceId, spaceId),
eq(sql`LOWER(${schema.serverProjects.name})`, n.toLowerCase()),
),
)
.get()
}
@@ -30,7 +37,15 @@ export function resolveOrCreateProject(
const now = new Date().toISOString()
getDb()
.insert(schema.serverProjects)
.values({ id, name: n, color: null, sortOrder: 0, notes: null, createdAt: now })
.values({
id,
spaceId: getCurrentSpaceId(),
name: n,
color: null,
sortOrder: 0,
notes: null,
createdAt: now,
})
.run()
return { id, name: n }
}
@@ -42,10 +57,12 @@ export function projectSuggestions(
const term = normalizeProjectNameInput(q)
const lim = Math.min(50, Math.max(1, Number(limit) || 20))
const db = getDb()
const spaceId = getCurrentSpaceId()
if (!term) {
return db
.select({ id: schema.serverProjects.id, name: schema.serverProjects.name })
.from(schema.serverProjects)
.where(eq(schema.serverProjects.spaceId, spaceId))
.orderBy(asc(schema.serverProjects.name))
.limit(lim)
.all()
@@ -55,7 +72,12 @@ export function projectSuggestions(
return db
.select({ id: schema.serverProjects.id, name: schema.serverProjects.name })
.from(schema.serverProjects)
.where(like(sql`LOWER(${schema.serverProjects.name})`, pattern))
.where(
and(
eq(schema.serverProjects.spaceId, spaceId),
like(sql`LOWER(${schema.serverProjects.name})`, pattern),
),
)
.orderBy(asc(schema.serverProjects.name))
.limit(lim)
.all()
@@ -81,17 +103,22 @@ function countVpsByProjectId(projectId: string): number {
export const projectsRepository = {
list(): (typeof schema.serverProjects.$inferSelect)[] {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.spaceId, spaceId))
.orderBy(asc(schema.serverProjects.name))
.all()
},
get(id: string): (typeof schema.serverProjects.$inferSelect) | undefined {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.id, id))
.where(
and(eq(schema.serverProjects.id, id), eq(schema.serverProjects.spaceId, spaceId)),
)
.get()
},
getDependencyCounts(id: string): { vps: number } {
@@ -104,6 +131,7 @@ export const projectsRepository = {
.insert(schema.serverProjects)
.values({
id,
spaceId: getCurrentSpaceId(),
name: input.name,
color: input.color ?? null,
sortOrder: 0,
@@ -142,7 +170,12 @@ export const projectsRepository = {
color: input.color !== undefined ? input.color : existing.color,
notes: input.notes !== undefined ? input.notes : existing.notes,
})
.where(eq(schema.serverProjects.id, id))
.where(
and(
eq(schema.serverProjects.id, id),
eq(schema.serverProjects.spaceId, existing.spaceId),
),
)
.run()
if (nextName !== existing.name) {
db.update(schema.vps)
@@ -154,7 +187,17 @@ export const projectsRepository = {
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.serverProjects).where(eq(schema.serverProjects.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.serverProjects)
.where(
and(
eq(schema.serverProjects.id, id),
eq(schema.serverProjects.spaceId, existing.spaceId),
),
)
.run()
return r.changes > 0
},
}
@@ -1,6 +1,7 @@
import { asc, count, eq } from 'drizzle-orm'
import { and, asc, count, eq } from 'drizzle-orm'
import { parseApiLogin } from '@cfdm/shared'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
type AccountRow = typeof schema.providerAccounts.$inferSelect
@@ -81,24 +82,47 @@ function countSyncLogForAccount(id: string): number {
export const providerAccountsRepository = {
list(): PublicAccountRow[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.spaceId, spaceId))
.orderBy(asc(schema.providerAccounts.name))
.all()
return rows.map((r) => sanitize(r)!) as PublicAccountRow[]
},
get(id: string): PublicAccountRow | undefined {
const spaceId = getCurrentSpaceId()
const row = getDb()
.select()
.from(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, spaceId),
),
)
.get()
return sanitize(row)
},
getWithCredentials(id: string): AccountRow | undefined {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.providerAccounts)
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, spaceId),
),
)
.get()
},
/** Unscoped lookup for sync/scheduler (account may be in any space). */
getWithCredentialsAnySpace(id: string): AccountRow | undefined {
return getDb()
.select()
.from(schema.providerAccounts)
@@ -106,6 +130,10 @@ export const providerAccountsRepository = {
.get()
},
listAllSpaces(): AccountRow[] {
return getDb().select().from(schema.providerAccounts).all()
},
getDependencyCounts(id: string): AccountDependencyCounts {
return {
vps: countVpsForAccount(id),
@@ -120,7 +148,7 @@ export const providerAccountsRepository = {
const db = getDb()
const finalId = id ?? input.id ?? generateId('account')
db.insert(schema.providerAccounts)
.values({ id: finalId, ...normalize(input) })
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
@@ -152,16 +180,29 @@ export const providerAccountsRepository = {
apiBaseUrl: '',
apiCredentials,
balanceAlertBelow,
spaceId: existing.spaceId,
})
.where(eq(schema.providerAccounts.id, id))
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, existing.spaceId),
),
)
.run()
return this.get(id)
},
delete(id: string): boolean {
const existing = this.getWithCredentials(id)
if (!existing) return false
const res = getDb()
.delete(schema.providerAccounts)
.where(eq(schema.providerAccounts.id, id))
.where(
and(
eq(schema.providerAccounts.id, id),
eq(schema.providerAccounts.spaceId, existing.spaceId),
),
)
.run()
return res.changes > 0
},
+24 -6
View File
@@ -1,5 +1,6 @@
import { asc, eq } from 'drizzle-orm'
import { and, asc, eq } from 'drizzle-orm'
import { getDb, schema, type Db } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
export type ProviderInsert = Partial<typeof schema.providers.$inferInsert> & {
@@ -22,18 +23,29 @@ function normalize(input: Partial<typeof schema.providers.$inferInsert>) {
export const providersRepository = {
list(): (typeof schema.providers.$inferSelect)[] {
return getDb().select().from(schema.providers).orderBy(asc(schema.providers.name)).all()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.orderBy(asc(schema.providers.name))
.all()
},
get(id: string): (typeof schema.providers.$inferSelect) | undefined {
return getDb().select().from(schema.providers).where(eq(schema.providers.id, id)).get()
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.providers)
.where(and(eq(schema.providers.id, id), eq(schema.providers.spaceId, spaceId)))
.get()
},
create(input: ProviderInsert, id?: string): (typeof schema.providers.$inferSelect) {
const db: Db = getDb()
const finalId = id ?? input.id ?? generateId('provider')
db.insert(schema.providers)
.values({ id: finalId, ...normalize(input) })
.values({ id: finalId, spaceId: getCurrentSpaceId(), ...normalize(input) })
.run()
return this.get(finalId)!
},
@@ -48,16 +60,22 @@ export const providersRepository = {
const merged = {
...existing,
...normalize({ ...existing, ...input }),
spaceId: existing.spaceId,
}
db.update(schema.providers)
.set(merged)
.where(eq(schema.providers.id, id))
.where(and(eq(schema.providers.id, id), eq(schema.providers.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
delete(id: string): boolean {
const res = getDb().delete(schema.providers).where(eq(schema.providers.id, id)).run()
const existing = this.get(id)
if (!existing) return false
const res = getDb()
.delete(schema.providers)
.where(and(eq(schema.providers.id, id), eq(schema.providers.spaceId, existing.spaceId)))
.run()
return res.changes > 0
},
}
+59 -10
View File
@@ -4,6 +4,10 @@ import {
type AppSwitcherConfig,
} from '@cfdm/shared/contracts/app-switcher'
import { getDb, schema } from '../index.js'
import {
getCurrentSpaceId,
settingsIdForSpace,
} from '../space-context.js'
type Row = typeof schema.settings.$inferSelect
@@ -150,9 +154,10 @@ interface SettingsInput {
showQuickActions?: boolean
}
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
function buildValues(id: string, spaceId: string, existing: Row | undefined, r: SettingsInput) {
return {
id,
spaceId,
baseCurrency: r.baseCurrency ?? existing?.baseCurrency ?? 'RUB',
ratesUrl: r.ratesUrl ?? existing?.ratesUrl ?? '',
autoConvert:
@@ -266,37 +271,68 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
export const settingsRepository = {
list(): SettingsDto[] {
const rows = getDb().select().from(schema.settings).orderBy(asc(schema.settings.id)).all()
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.settings)
.where(eq(schema.settings.spaceId, spaceId))
.orderBy(asc(schema.settings.id))
.all()
return rows.map((r) => toDto(r)!) as SettingsDto[]
},
listAllSpaces(): Row[] {
return getDb().select().from(schema.settings).all()
},
get(id: string): SettingsDto | undefined {
return toDto(getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get())
},
getRow(id: string): Row | undefined {
return getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get()
},
getIntegrationToken(id = 'settings-main'): string {
return this.getRow(id)?.integrationToken?.trim() ?? ''
getBySpace(spaceId = getCurrentSpaceId()): Row | undefined {
return getDb()
.select()
.from(schema.settings)
.where(eq(schema.settings.spaceId, spaceId))
.get()
},
getAppSwitcher(id = 'settings-main'): AppSwitcherConfig {
const row = this.getRow(id)
getDtoBySpace(spaceId = getCurrentSpaceId()): SettingsDto | undefined {
return toDto(this.getBySpace(spaceId))
},
findByIntegrationToken(token: string): Row | undefined {
const t = token.trim()
if (!t) return undefined
const rows = getDb().select().from(schema.settings).all()
return rows.find((r) => r.integrationEnabled && r.integrationToken?.trim() === t)
},
getIntegrationToken(id?: string): string {
const row = id
? this.getRow(id)
: this.getBySpace(getCurrentSpaceId())
return row?.integrationToken?.trim() ?? ''
},
getAppSwitcher(id?: string): AppSwitcherConfig {
const row = id
? this.getRow(id)
: this.getBySpace(getCurrentSpaceId()) ?? this.getRow('settings-main')
return parseAppSwitcher(row?.appSwitcherJson)
},
touchIntegrationSync(id = 'settings-main'): void {
touchIntegrationSync(id?: string): void {
const db = getDb()
const at = new Date().toISOString()
const existing = this.getRow(id)
const existing = id ? this.getRow(id) : this.getBySpace(getCurrentSpaceId())
if (existing) {
db.update(schema.settings)
.set({ integrationLastSyncAt: at })
.where(eq(schema.settings.id, id))
.where(eq(schema.settings.id, existing.id))
.run()
}
},
upsert(id: string, input: SettingsInput): SettingsDto {
const db = getDb()
const existing = this.getRow(id)
const values = buildValues(id, existing, input)
const spaceId = existing?.spaceId ?? getCurrentSpaceId()
const values = buildValues(id, spaceId, existing, input)
if (existing) {
db.update(schema.settings).set(values).where(eq(schema.settings.id, id)).run()
} else {
@@ -304,4 +340,17 @@ export const settingsRepository = {
}
return this.get(id)!
},
upsertForSpace(spaceId: string, input: SettingsInput): SettingsDto {
const id = settingsIdForSpace(spaceId)
const existing = this.getRow(id) ?? this.getBySpace(spaceId)
const finalId = existing?.id ?? id
const db = getDb()
const values = buildValues(finalId, spaceId, existing, input)
if (existing) {
db.update(schema.settings).set(values).where(eq(schema.settings.id, existing.id)).run()
return this.get(existing.id)!
}
db.insert(schema.settings).values(values).run()
return this.get(finalId)!
},
}
+33 -3
View File
@@ -1,4 +1,4 @@
import { vpsRepository } from './vps.js'
import { vpsRepository, type VpsDto } from './vps.js'
import { providersRepository } from './providers.js'
import { providerAccountsRepository } from './provider-accounts.js'
import { paymentsRepository } from './payments.js'
@@ -8,9 +8,12 @@ import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.
import { projectsRepository } from './projects.js'
import { syncLogRepository } from './sync-log.js'
import { vpsDomainsRepository } from './vps-domains.js'
import { vpsGrantsRepository } from './spaces.js'
import { getCurrentSpaceId } from '../space-context.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
spaceId: string
vps: VpsDto[]
serverProjects: ReturnType<typeof projectsRepository.list>
providers: ReturnType<typeof providersRepository.list>
providerAccounts: ReturnType<typeof providerAccountsRepository.list>
@@ -21,11 +24,36 @@ export interface Snapshot {
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
syncLog: ReturnType<typeof syncLogRepository.listRecent>
vpsDomains: ReturnType<typeof vpsDomainsRepository.list>
vpsGrants: ReturnType<typeof vpsGrantsRepository.listToSpace>
}
function listVpsWithShared(): VpsDto[] {
const spaceId = getCurrentSpaceId()
const owned = vpsRepository.list()
const ownedIds = new Set(owned.map((v) => v.id))
const grants = vpsGrantsRepository.listToSpace(spaceId)
const sharedIds = grants.map((g) => g.vpsId).filter((id) => !ownedIds.has(id))
const sharedRows = vpsRepository.listByIds(sharedIds)
const grantByVps = new Map(grants.map((g) => [g.vpsId, g]))
const shared = sharedRows.map((v) => {
const g = grantByVps.get(v.id)
return {
...v,
access: 'shared' as const,
grantPermission: (g?.permission === 'write' ? 'write' : 'read') as 'read' | 'write',
// Hide credentials linkage for shared view
providerAccountId: '',
providerId: v.providerId ?? '',
}
})
return [...owned, ...shared]
}
export function getSnapshot(): Snapshot {
const spaceId = getCurrentSpaceId()
return {
vps: vpsRepository.list(),
spaceId,
vps: listVpsWithShared(),
serverProjects: projectsRepository.list(),
providers: providersRepository.list(),
providerAccounts: providerAccountsRepository.list(),
@@ -36,6 +64,7 @@ export function getSnapshot(): Snapshot {
tariffSyncOptions: tariffSyncOptionsRepository.list(),
syncLog: syncLogRepository.listRecent(50),
vpsDomains: vpsDomainsRepository.list(),
vpsGrants: vpsGrantsRepository.listToSpace(spaceId),
}
}
@@ -51,4 +80,5 @@ export {
projectsRepository,
syncLogRepository,
vpsDomainsRepository,
vpsGrantsRepository,
}
+366
View File
@@ -0,0 +1,366 @@
import { and, asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
import {
MAIN_SPACE_ID,
getCurrentSpaceId,
settingsIdForSpace,
} from '../space-context.js'
export type SpaceRole = 'owner' | 'admin' | 'member' | 'viewer'
export type SpaceKind = 'main' | 'personal'
export type GrantPermission = 'read' | 'write'
export type SpaceRow = typeof schema.spaces.$inferSelect
export type SpaceMemberRow = typeof schema.spaceMembers.$inferSelect
export type VpsGrantRow = typeof schema.vpsGrants.$inferSelect
const ROLE_RANK: Record<SpaceRole, number> = {
viewer: 1,
member: 2,
admin: 3,
owner: 4,
}
export function roleAtLeast(role: string, min: SpaceRole): boolean {
return (ROLE_RANK[role as SpaceRole] ?? 0) >= ROLE_RANK[min]
}
function nowIso(): string {
return new Date().toISOString()
}
export const spacesRepository = {
listAll(): SpaceRow[] {
return getDb().select().from(schema.spaces).orderBy(asc(schema.spaces.name)).all()
},
listForUser(userId: string, isAdmin = false): (SpaceRow & { role: string })[] {
if (isAdmin) {
return this.listAll().map((s) => {
const m = this.getMember(s.id, userId)
return { ...s, role: m?.role ?? (s.kind === 'main' ? 'admin' : 'viewer') }
})
}
const db = getDb()
const members = db
.select()
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, userId))
.all()
const out: (SpaceRow & { role: string })[] = []
for (const m of members) {
const space = this.get(m.spaceId)
if (space) out.push({ ...space, role: m.role })
}
return out.sort((a, b) => a.name.localeCompare(b.name))
},
get(id: string): SpaceRow | undefined {
return getDb().select().from(schema.spaces).where(eq(schema.spaces.id, id)).get()
},
getMain(): SpaceRow {
let row = this.get(MAIN_SPACE_ID)
if (!row) {
row = this.create({
id: MAIN_SPACE_ID,
name: 'Основное',
slug: 'main',
kind: 'main',
ownerUserId: process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null,
})
}
return row
},
create(input: {
id?: string
name: string
slug: string
kind?: SpaceKind
ownerUserId?: string | null
}): SpaceRow {
const db = getDb()
const id = input.id ?? generateId('space')
const createdAt = nowIso()
db.insert(schema.spaces)
.values({
id,
name: input.name,
slug: input.slug,
kind: input.kind ?? 'personal',
ownerUserId: input.ownerUserId ?? null,
createdAt,
})
.run()
if (input.ownerUserId) {
db.insert(schema.spaceMembers)
.values({
spaceId: id,
userId: input.ownerUserId,
role: 'owner',
createdAt,
})
.run()
}
// Seed settings for the space
const settingsId = settingsIdForSpace(id)
const existingSettings = db
.select()
.from(schema.settings)
.where(eq(schema.settings.id, settingsId))
.get()
if (!existingSettings) {
db.insert(schema.settings)
.values({
id: settingsId,
spaceId: id,
baseCurrency: 'RUB',
syncEnabled: 0,
autoConvert: 0,
})
.run()
}
return this.get(id)!
},
update(
id: string,
input: Partial<{ name: string; slug: string; ownerUserId: string | null }>,
): SpaceRow | undefined {
const existing = this.get(id)
if (!existing) return undefined
getDb()
.update(schema.spaces)
.set({
name: input.name ?? existing.name,
slug: input.slug ?? existing.slug,
ownerUserId:
input.ownerUserId !== undefined ? input.ownerUserId : existing.ownerUserId,
})
.where(eq(schema.spaces.id, id))
.run()
return this.get(id)
},
ensurePersonalSpace(userId: string, name?: string): SpaceRow {
const id = `space-user-${userId}`
const existing = this.get(id)
if (existing) {
const member = this.getMember(id, userId)
if (!member) {
this.addMember(id, userId, 'owner')
}
return existing
}
return this.create({
id,
name: name?.trim() || 'Моё пространство',
slug: `user-${userId}`,
kind: 'personal',
ownerUserId: userId,
})
},
claimMainOwnerIfEmpty(userId: string): void {
const main = this.getMain()
if (!main.ownerUserId) {
this.update(MAIN_SPACE_ID, { ownerUserId: userId })
}
const member = this.getMember(MAIN_SPACE_ID, userId)
if (!member) {
this.addMember(MAIN_SPACE_ID, userId, 'owner')
} else if (!roleAtLeast(member.role, 'admin')) {
this.updateMember(MAIN_SPACE_ID, userId, 'owner')
}
},
getMember(spaceId: string, userId: string): SpaceMemberRow | undefined {
return getDb()
.select()
.from(schema.spaceMembers)
.where(
and(
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
),
)
.get()
},
listMembers(spaceId: string): SpaceMemberRow[] {
return getDb()
.select()
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, spaceId))
.all()
},
addMember(spaceId: string, userId: string, role: SpaceRole = 'member'): SpaceMemberRow {
const existing = this.getMember(spaceId, userId)
if (existing) {
return this.updateMember(spaceId, userId, role) ?? existing
}
getDb()
.insert(schema.spaceMembers)
.values({
spaceId,
userId,
role,
createdAt: nowIso(),
})
.run()
return this.getMember(spaceId, userId)!
},
updateMember(
spaceId: string,
userId: string,
role: SpaceRole,
): SpaceMemberRow | undefined {
const existing = this.getMember(spaceId, userId)
if (!existing) return undefined
getDb()
.update(schema.spaceMembers)
.set({ role })
.where(
and(
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
),
)
.run()
return this.getMember(spaceId, userId)
},
removeMember(spaceId: string, userId: string): boolean {
const r = getDb()
.delete(schema.spaceMembers)
.where(
and(
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
),
)
.run()
return r.changes > 0
},
canAccess(spaceId: string, userId: string, isAdmin = false): boolean {
if (isAdmin) return true
return Boolean(this.getMember(spaceId, userId))
},
requireRole(
spaceId: string,
userId: string,
min: SpaceRole,
isAdmin = false,
): SpaceMemberRow | null {
if (isAdmin) {
return (
this.getMember(spaceId, userId) ?? {
spaceId,
userId,
role: 'owner',
createdAt: nowIso(),
}
)
}
const m = this.getMember(spaceId, userId)
if (!m || !roleAtLeast(m.role, min)) return null
return m
},
}
export const vpsGrantsRepository = {
listToSpace(toSpaceId: string): VpsGrantRow[] {
return getDb()
.select()
.from(schema.vpsGrants)
.where(eq(schema.vpsGrants.toSpaceId, toSpaceId))
.all()
},
listFromSpace(fromSpaceId: string): VpsGrantRow[] {
return getDb()
.select()
.from(schema.vpsGrants)
.where(eq(schema.vpsGrants.fromSpaceId, fromSpaceId))
.all()
},
get(id: string): VpsGrantRow | undefined {
return getDb().select().from(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).get()
},
getForVpsToSpace(vpsId: string, toSpaceId: string): VpsGrantRow | undefined {
return getDb()
.select()
.from(schema.vpsGrants)
.where(
and(
eq(schema.vpsGrants.vpsId, vpsId),
eq(schema.vpsGrants.toSpaceId, toSpaceId),
),
)
.get()
},
create(input: {
vpsId: string
fromSpaceId: string
toSpaceId: string
permission: GrantPermission
grantedByUserId?: string | null
}): VpsGrantRow {
const existing = this.getForVpsToSpace(input.vpsId, input.toSpaceId)
if (existing) {
getDb()
.update(schema.vpsGrants)
.set({
permission: input.permission,
grantedByUserId: input.grantedByUserId ?? existing.grantedByUserId,
})
.where(eq(schema.vpsGrants.id, existing.id))
.run()
return this.get(existing.id)!
}
const id = generateId('grant')
getDb()
.insert(schema.vpsGrants)
.values({
id,
vpsId: input.vpsId,
fromSpaceId: input.fromSpaceId,
toSpaceId: input.toSpaceId,
permission: input.permission,
grantedByUserId: input.grantedByUserId ?? null,
createdAt: nowIso(),
})
.run()
return this.get(id)!
},
delete(id: string): boolean {
const r = getDb().delete(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).run()
return r.changes > 0
},
deleteByVps(vpsId: string): number {
const r = getDb()
.delete(schema.vpsGrants)
.where(eq(schema.vpsGrants.vpsId, vpsId))
.run()
return r.changes
},
/** Effective grant for current space context on a VPS owned elsewhere. */
getGrantInCurrentSpace(vpsId: string): VpsGrantRow | undefined {
return this.getForVpsToSpace(vpsId, getCurrentSpaceId())
},
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { desc } from 'drizzle-orm'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
export interface SyncLogDto {
id: string
@@ -38,9 +39,11 @@ function toDto(row: typeof schema.syncLog.$inferSelect): SyncLogDto {
export const syncLogRepository = {
listRecent(limit = 50): SyncLogDto[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.syncLog)
.where(eq(schema.syncLog.spaceId, spaceId))
.orderBy(desc(schema.syncLog.startedAt))
.limit(limit)
.all()
+16 -5
View File
@@ -1,5 +1,6 @@
import { asc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
type Row = typeof schema.activeTariffs.$inferSelect
@@ -94,9 +95,11 @@ function toDto(row: Row | undefined): ActiveTariffDto | undefined {
export const activeTariffsRepository = {
list(): ActiveTariffDto[] {
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.spaceId, spaceId))
.orderBy(asc(schema.activeTariffs.name))
.all()
return rows.map((r) => toDto(r)!) as ActiveTariffDto[]
@@ -111,16 +114,18 @@ export const activeTariffsRepository = {
},
upsertMany(rows: (typeof schema.activeTariffs.$inferInsert)[]): void {
const db = getDb()
const spaceId = getCurrentSpaceId()
for (const r of rows) {
const withSpace = { ...r, spaceId: r.spaceId ?? spaceId }
const existing = db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.id, r.id))
.get()
if (existing) {
db.update(schema.activeTariffs).set(r).where(eq(schema.activeTariffs.id, r.id)).run()
db.update(schema.activeTariffs).set(withSpace).where(eq(schema.activeTariffs.id, r.id)).run()
} else {
db.insert(schema.activeTariffs).values(r).run()
db.insert(schema.activeTariffs).values(withSpace).run()
}
}
},
@@ -160,7 +165,12 @@ export function toTariffSyncOptionsDto(
export const tariffSyncOptionsRepository = {
list(): TariffSyncOptionsDto[] {
const rows = getDb().select().from(schema.tariffSyncOptions).all()
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.tariffSyncOptions)
.where(eq(schema.tariffSyncOptions.spaceId, spaceId))
.all()
return rows.map((r) => toTariffSyncOptionsDto(r)!) as TariffSyncOptionsDto[]
},
byAccount(accountId: string): TariffSyncOptionsDto | undefined {
@@ -174,6 +184,7 @@ export const tariffSyncOptionsRepository = {
},
upsert(input: typeof schema.tariffSyncOptions.$inferInsert): void {
const db = getDb()
const withSpace = { ...input, spaceId: input.spaceId ?? getCurrentSpaceId() }
const existing = db
.select({ providerAccountId: schema.tariffSyncOptions.providerAccountId })
.from(schema.tariffSyncOptions)
@@ -181,11 +192,11 @@ export const tariffSyncOptionsRepository = {
.get()
if (existing) {
db.update(schema.tariffSyncOptions)
.set(input)
.set(withSpace)
.where(eq(schema.tariffSyncOptions.providerAccountId, input.providerAccountId))
.run()
} else {
db.insert(schema.tariffSyncOptions).values(input).run()
db.insert(schema.tariffSyncOptions).values(withSpace).run()
}
},
}
+24 -4
View File
@@ -1,6 +1,8 @@
import { asc, eq, isNull } from 'drizzle-orm'
import { and, asc, eq, isNull } from 'drizzle-orm'
// and used in markOrphaned / listUnmatched
import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
import { vpsRepository } from './vps.js'
@@ -45,9 +47,11 @@ function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' {
export const vpsDomainsRepository = {
list(): VpsDomainDto[] {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.spaceId, spaceId))
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
@@ -78,8 +82,13 @@ export const vpsDomainsRepository = {
rematchAll(): { updated: number } {
const db = getDb()
const spaceId = getCurrentSpaceId()
const allVps = vpsRepository.list()
const rows = db.select().from(schema.vpsDomains).all()
const rows = db
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.spaceId, spaceId))
.all()
let updated = 0
const vpsIds = new Set(allVps.map((v) => v.id))
@@ -123,6 +132,7 @@ export const vpsDomainsRepository = {
upserted: number
} {
const db = getDb()
const spaceId = getCurrentSpaceId()
const allVps = vpsRepository.list()
const now = new Date().toISOString()
let matched = 0
@@ -143,6 +153,7 @@ export const vpsDomainsRepository = {
const existing = this.getByCfdmBindingId(item.bindingId)
const values = {
spaceId,
vpsId,
fqdn: item.fqdn,
zoneName: item.zoneName,
@@ -170,10 +181,16 @@ export const vpsDomainsRepository = {
markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number {
const db = getDb()
const spaceId = getCurrentSpaceId()
const rows = db
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmServiceId, serviceId))
.where(
and(
eq(schema.vpsDomains.cfdmServiceId, serviceId),
eq(schema.vpsDomains.spaceId, spaceId),
),
)
.all()
let removed = 0
for (const row of rows) {
@@ -186,10 +203,13 @@ export const vpsDomainsRepository = {
},
listUnmatched(): VpsDomainDto[] {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.vpsDomains)
.where(isNull(schema.vpsDomains.vpsId))
.where(
and(eq(schema.vpsDomains.spaceId, spaceId), isNull(schema.vpsDomains.vpsId)),
)
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
+145 -9
View File
@@ -1,5 +1,6 @@
import { desc, eq, inArray } from 'drizzle-orm'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
import { resolveOrCreateProject, getProjectNameById } from './projects.js'
@@ -13,6 +14,8 @@ export type VpsDto = Omit<VpsRow, 'additionalIps' | 'userOverrides' | 'projectId
backupEnabled: boolean
dailyRate: number | ''
monthlyRate: number | ''
access?: 'owned' | 'shared'
grantPermission?: 'read' | 'write'
}
const USER_OVERRIDABLE_FIELDS = [
@@ -133,13 +136,37 @@ function serializeCustomData(v: unknown): string | null {
return JSON.stringify(v)
}
function spaceFilter() {
return eq(schema.vps.spaceId, getCurrentSpaceId())
}
export const vpsRepository = {
list(): VpsDto[] {
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const rows = getDb()
.select()
.from(schema.vps)
.where(spaceFilter())
.orderBy(desc(schema.vps.createdAt))
.all()
return rows.map((r) => ({ ...toDto(r)!, access: 'owned' as const }))
},
listByIds(ids: string[]): VpsDto[] {
if (ids.length === 0) return []
const rows = getDb().select().from(schema.vps).where(inArray(schema.vps.id, ids)).all()
return rows.map((r) => toDto(r)!) as VpsDto[]
},
get(id: string): VpsDto | undefined {
const row = getDb()
.select()
.from(schema.vps)
.where(and(eq(schema.vps.id, id), spaceFilter()))
.get()
return row ? { ...toDto(row)!, access: 'owned' } : undefined
},
getAnySpace(id: string): VpsDto | undefined {
const row = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
return toDto(row)
},
@@ -152,6 +179,7 @@ export const vpsRepository = {
db.insert(schema.vps)
.values({
id: finalId,
spaceId: getCurrentSpaceId(),
ip: input.ip ?? '',
ipv6: input.ipv6 ?? '',
additionalIps,
@@ -195,7 +223,11 @@ export const vpsRepository = {
update(id: string, input: VpsInput): VpsDto | undefined {
const db = getDb()
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
const existing = getDb()
.select()
.from(schema.vps)
.where(and(eq(schema.vps.id, id), spaceFilter()))
.get()
if (!existing) return undefined
let userOverrides: string[] = []
@@ -284,36 +316,140 @@ export const vpsRepository = {
paidUntil: input.paidUntil ?? '',
notes: input.notes ?? '',
userOverrides: userOverridesJson,
spaceId: existing.spaceId,
...(input.customData !== undefined
? { customData: serializeCustomData(input.customData) }
: {}),
})
.where(eq(schema.vps.id, id))
.where(and(eq(schema.vps.id, id), eq(schema.vps.spaceId, existing.spaceId)))
.run()
return this.get(id)
},
/** Update VPS by id regardless of current space (for shared write grants). */
updateAnySpace(id: string, input: VpsInput): VpsDto | undefined {
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
if (!existing) return undefined
// Temporarily treat as owned update in its home space via raw set of fields
const additionalIps = Array.isArray(input.additionalIps)
? JSON.stringify(input.additionalIps)
: existing.additionalIps ?? '[]'
let projectOut = existing.project ?? ''
let projectIdOut = existing.projectId ?? ''
if (input.project !== undefined) {
const r = projectColumnsForSave(input.project)
projectOut = r.project
projectIdOut = r.projectId
}
getDb()
.update(schema.vps)
.set({
ip: input.ip ?? existing.ip ?? '',
ipv6: input.ipv6 ?? existing.ipv6 ?? '',
additionalIps,
dns: input.dns ?? existing.dns ?? '',
country: input.country ?? existing.country ?? '',
city: input.city ?? existing.city ?? '',
datacenter: input.datacenter ?? existing.datacenter ?? '',
os: input.os ?? existing.os ?? '',
vcpu: input.vcpu ?? existing.vcpu ?? 0,
ramGb: input.ramGb ?? existing.ramGb ?? 0,
diskGb: input.diskGb ?? existing.diskGb ?? 0,
diskType: input.diskType ?? existing.diskType ?? '',
virtualization: input.virtualization ?? existing.virtualization ?? '',
bandwidthTb: input.bandwidthTb ?? existing.bandwidthTb ?? 0,
sshPort: input.sshPort ?? existing.sshPort ?? 22,
rootUser: input.rootUser ?? existing.rootUser ?? '',
purpose: input.purpose ?? existing.purpose ?? '',
environment: input.environment ?? existing.environment ?? '',
project: projectOut,
projectId: projectIdOut || null,
monitoringEnabled:
input.monitoringEnabled !== undefined
? boolToInt(input.monitoringEnabled)
: existing.monitoringEnabled,
backupEnabled:
input.backupEnabled !== undefined
? boolToInt(input.backupEnabled)
: existing.backupEnabled,
status: input.status ?? existing.status ?? 'active',
tariffType: input.tariffType ?? existing.tariffType ?? '',
currency: input.currency ?? existing.currency ?? '',
dailyRate:
input.dailyRate !== undefined ? numOrNull(input.dailyRate) : existing.dailyRate,
monthlyRate:
input.monthlyRate !== undefined ? numOrNull(input.monthlyRate) : existing.monthlyRate,
paidUntil: input.paidUntil ?? existing.paidUntil ?? '',
notes: input.notes ?? existing.notes ?? '',
// Do not change providerAccountId / providerId / spaceId on shared edit
})
.where(eq(schema.vps.id, id))
.run()
return this.getAnySpace(id)
},
assignToSpace(id: string, toSpaceId: string): VpsDto | undefined {
const existing = getDb().select().from(schema.vps).where(eq(schema.vps.id, id)).get()
if (!existing) return undefined
getDb()
.update(schema.vps)
.set({
spaceId: toSpaceId,
providerId: null,
providerAccountId: null,
projectId: null,
project: '',
})
.where(eq(schema.vps.id, id))
.run()
return this.getAnySpace(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.vps).where(eq(schema.vps.id, id)).run()
const existing = getDb()
.select()
.from(schema.vps)
.where(and(eq(schema.vps.id, id), spaceFilter()))
.get()
if (!existing) return false
const r = getDb()
.delete(schema.vps)
.where(and(eq(schema.vps.id, id), eq(schema.vps.spaceId, existing.spaceId)))
.run()
return r.changes > 0
},
bulkStatus(ids: string[], status: string): number {
getDb().update(schema.vps).set({ status }).where(inArray(schema.vps.id, ids)).run()
return ids.length
const spaceId = getCurrentSpaceId()
const owned = getDb()
.select({ id: schema.vps.id })
.from(schema.vps)
.where(and(inArray(schema.vps.id, ids), eq(schema.vps.spaceId, spaceId)))
.all()
.map((r) => r.id)
if (owned.length === 0) return 0
getDb().update(schema.vps).set({ status }).where(inArray(schema.vps.id, owned)).run()
return owned.length
},
bulkDelete(ids: string[]): number {
const r = getDb().delete(schema.vps).where(inArray(schema.vps.id, ids)).run()
const spaceId = getCurrentSpaceId()
const r = getDb()
.delete(schema.vps)
.where(and(inArray(schema.vps.id, ids), eq(schema.vps.spaceId, spaceId)))
.run()
return r.changes
},
bulkProject(ids: string[], project: string | null): { updated: number; project: string; projectId: string } {
const { project: projName, projectId: projId } = projectColumnsForSave(project ?? '')
const spaceId = getCurrentSpaceId()
const rows = getDb()
.select()
.from(schema.vps)
.where(inArray(schema.vps.id, ids))
.where(and(inArray(schema.vps.id, ids), eq(schema.vps.spaceId, spaceId)))
.all()
let updated = 0
for (const row of rows) {
+97
View File
@@ -1,5 +1,7 @@
import type Database from 'better-sqlite3'
const MAIN_SPACE_ID = 'space-main'
const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE vps ADD COLUMN customData TEXT`,
`ALTER TABLE vps ADD COLUMN last_health_status TEXT`,
@@ -16,9 +18,40 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
`ALTER TABLE providers ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN spaceId TEXT`,
`ALTER TABLE server_projects ADD COLUMN spaceId TEXT`,
`ALTER TABLE vps ADD COLUMN spaceId TEXT`,
`ALTER TABLE payments ADD COLUMN spaceId TEXT`,
`ALTER TABLE balance_ledger ADD COLUMN spaceId TEXT`,
`ALTER TABLE settings ADD COLUMN spaceId TEXT`,
`ALTER TABLE vps_domains ADD COLUMN spaceId TEXT`,
`ALTER TABLE notification_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE notification_state ADD COLUMN spaceId TEXT`,
`ALTER TABLE vps_health_checks ADD COLUMN spaceId TEXT`,
`ALTER TABLE audit_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE audit_log ADD COLUMN actorUserId TEXT`,
`ALTER TABLE sync_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`,
`ALTER TABLE tariff_sync_options ADD COLUMN spaceId TEXT`,
]
const TABLE_MIGRATIONS: string[] = [
`CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'personal',
ownerUserId TEXT,
createdAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS space_members (
spaceId TEXT NOT NULL REFERENCES spaces(id),
userId TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
createdAt TEXT NOT NULL,
UNIQUE(spaceId, userId)
)`,
`CREATE TABLE IF NOT EXISTS vps_health_checks (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL REFERENCES vps(id),
@@ -74,8 +107,70 @@ const TABLE_MIGRATIONS: string[] = [
targetIps TEXT,
syncedAt TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS vps_grants (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL REFERENCES vps(id),
fromSpaceId TEXT NOT NULL REFERENCES spaces(id),
toSpaceId TEXT NOT NULL REFERENCES spaces(id),
permission TEXT NOT NULL DEFAULT 'read',
grantedByUserId TEXT,
createdAt TEXT NOT NULL,
UNIQUE(vpsId, toSpaceId)
)`,
]
const SPACE_BACKFILL_TABLES = [
'providers',
'provider_accounts',
'server_projects',
'vps',
'payments',
'balance_ledger',
'settings',
'vps_domains',
'notification_log',
'notification_state',
'vps_health_checks',
'audit_log',
'sync_log',
'active_tariffs',
'tariff_sync_options',
] as const
function ensureMainSpace(sqlite: Database.Database): void {
const now = new Date().toISOString()
const owner = process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null
sqlite
.prepare(
`INSERT OR IGNORE INTO spaces (id, name, slug, kind, ownerUserId, createdAt)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(MAIN_SPACE_ID, 'Основное', 'main', 'main', owner, now)
if (owner) {
sqlite
.prepare(
`INSERT OR IGNORE INTO space_members (spaceId, userId, role, createdAt)
VALUES (?, ?, 'owner', ?)`,
)
.run(MAIN_SPACE_ID, owner, now)
}
}
function backfillSpaceIds(sqlite: Database.Database): void {
for (const table of SPACE_BACKFILL_TABLES) {
try {
sqlite
.prepare(
`UPDATE ${table} SET spaceId = ? WHERE spaceId IS NULL OR spaceId = ''`,
)
.run(MAIN_SPACE_ID)
} catch {
/* table may not exist yet */
}
}
}
let migrated = false
export function resetRuntimeMigrate(): void {
@@ -94,5 +189,7 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
/* column exists */
}
}
ensureMainSpace(sqlite)
backfillSpaceIds(sqlite)
migrated = true
}
+108 -1
View File
@@ -1,8 +1,58 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const spaces = sqliteTable('spaces', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
kind: text('kind').notNull().default('personal'),
ownerUserId: text('ownerUserId'),
createdAt: text('createdAt').notNull(),
})
export const spaceMembers = sqliteTable(
'space_members',
{
spaceId: text('spaceId')
.notNull()
.references(() => spaces.id),
userId: text('userId').notNull(),
role: text('role').notNull().default('member'),
createdAt: text('createdAt').notNull(),
},
(t) => ({
pk: uniqueIndex('space_members_pk').on(t.spaceId, t.userId),
}),
)
export const vpsGrants = sqliteTable(
'vps_grants',
{
id: text('id').primaryKey(),
vpsId: text('vpsId')
.notNull()
.references(() => vps.id),
fromSpaceId: text('fromSpaceId')
.notNull()
.references(() => spaces.id),
toSpaceId: text('toSpaceId')
.notNull()
.references(() => spaces.id),
permission: text('permission').notNull().default('read'),
grantedByUserId: text('grantedByUserId'),
createdAt: text('createdAt').notNull(),
},
(t) => ({
uniq: uniqueIndex('vps_grants_vps_to').on(t.vpsId, t.toSpaceId),
}),
)
export const providers = sqliteTable('providers', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
name: text('name').notNull(),
website: text('website'),
contact: text('contact'),
@@ -16,6 +66,10 @@ export const providers = sqliteTable('providers', {
export const providerAccounts = sqliteTable('provider_accounts', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
providerId: text('providerId')
.notNull()
.references(() => providers.id),
@@ -36,6 +90,10 @@ export const providerAccounts = sqliteTable('provider_accounts', {
export const serverProjects = sqliteTable('server_projects', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
name: text('name').notNull(),
color: text('color'),
sortOrder: integer('sortOrder').default(0),
@@ -45,6 +103,10 @@ export const serverProjects = sqliteTable('server_projects', {
export const vps = sqliteTable('vps', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
ip: text('ip'),
ipv6: text('ipv6'),
additionalIps: text('additionalIps'),
@@ -85,6 +147,10 @@ export const vps = sqliteTable('vps', {
export const payments = sqliteTable('payments', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
type: text('type').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
@@ -96,6 +162,10 @@ export const payments = sqliteTable('payments', {
export const balanceLedger = sqliteTable('balance_ledger', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
type: text('type').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
@@ -108,6 +178,10 @@ export const balanceLedger = sqliteTable('balance_ledger', {
export const settings = sqliteTable('settings', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
baseCurrency: text('baseCurrency'),
ratesUrl: text('ratesUrl'),
autoConvert: integer('autoConvert'),
@@ -138,6 +212,10 @@ export const settings = sqliteTable('settings', {
export const vpsDomains = sqliteTable('vps_domains', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
fqdn: text('fqdn').notNull(),
zoneName: text('zoneName').notNull(),
@@ -154,6 +232,10 @@ export const vpsDomains = sqliteTable('vps_domains', {
export const notificationLog = sqliteTable('notification_log', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
event: text('event').notNull(),
channel: text('channel').notNull(),
status: text('status').notNull(),
@@ -165,6 +247,10 @@ export const notificationLog = sqliteTable('notification_log', {
export const notificationState = sqliteTable('notification_state', {
key: text('key').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
lastFingerprint: text('lastFingerprint'),
lastSentAt: text('lastSentAt'),
lastStatus: text('lastStatus'),
@@ -172,6 +258,10 @@ export const notificationState = sqliteTable('notification_state', {
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
vpsId: text('vpsId')
.notNull()
.references(() => vps.id),
@@ -183,15 +273,24 @@ export const vpsHealthChecks = sqliteTable('vps_health_checks', {
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
entity: text('entity').notNull(),
entityId: text('entityId').notNull(),
action: text('action').notNull(),
diff: text('diff'),
actorUserId: text('actorUserId'),
createdAt: text('createdAt').notNull(),
})
export const syncLog = sqliteTable('sync_log', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
accountId: text('accountId')
.notNull()
.references(() => providerAccounts.id),
@@ -206,6 +305,10 @@ export const syncLog = sqliteTable('sync_log', {
export const activeTariffs = sqliteTable('active_tariffs', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
providerAccountId: text('providerAccountId')
.notNull()
.references(() => providerAccounts.id),
@@ -235,6 +338,10 @@ export const tariffSyncOptions = sqliteTable('tariff_sync_options', {
providerAccountId: text('providerAccountId')
.primaryKey()
.references(() => providerAccounts.id),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
datacenters: text('datacenters'),
periods: text('periods'),
syncedAt: text('syncedAt'),
+24
View File
@@ -0,0 +1,24 @@
import { AsyncLocalStorage } from 'node:async_hooks'
export const MAIN_SPACE_ID = 'space-main'
const storage = new AsyncLocalStorage<{ spaceId: string }>()
export function runWithSpace<T>(spaceId: string, fn: () => T): T {
return storage.run({ spaceId }, fn)
}
export async function runWithSpaceAsync<T>(
spaceId: string,
fn: () => Promise<T>,
): Promise<T> {
return storage.run({ spaceId }, fn)
}
export function getCurrentSpaceId(): string {
return storage.getStore()?.spaceId ?? MAIN_SPACE_ID
}
export function settingsIdForSpace(spaceId: string): string {
return spaceId === MAIN_SPACE_ID ? 'settings-main' : `settings-${spaceId}`
}
+82 -3
View File
@@ -2,8 +2,26 @@ import { closeDb, getSqlite } from './index.js'
import { resetRuntimeMigrate } from './runtime-migrate.js'
const TEST_SCHEMA = `
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'personal',
ownerUserId TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS space_members (
spaceId TEXT NOT NULL,
userId TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
createdAt TEXT NOT NULL,
UNIQUE(spaceId, userId)
);
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
name TEXT NOT NULL,
website TEXT,
contact TEXT,
@@ -17,6 +35,7 @@ CREATE TABLE IF NOT EXISTS providers (
CREATE TABLE IF NOT EXISTS provider_accounts (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
providerId TEXT NOT NULL,
name TEXT NOT NULL,
panelUrl TEXT,
@@ -36,6 +55,7 @@ CREATE TABLE IF NOT EXISTS provider_accounts (
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
ip TEXT,
ipv6 TEXT,
additionalIps TEXT,
@@ -76,8 +96,20 @@ CREATE TABLE IF NOT EXISTS vps (
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS vps_grants (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL,
fromSpaceId TEXT NOT NULL,
toSpaceId TEXT NOT NULL,
permission TEXT NOT NULL DEFAULT 'read',
grantedByUserId TEXT,
createdAt TEXT NOT NULL,
UNIQUE(vpsId, toSpaceId)
);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
@@ -90,6 +122,7 @@ CREATE TABLE IF NOT EXISTS payments (
CREATE TABLE IF NOT EXISTS balance_ledger (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
@@ -103,6 +136,7 @@ CREATE TABLE IF NOT EXISTS balance_ledger (
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
accountId TEXT NOT NULL,
startedAt TEXT NOT NULL,
finishedAt TEXT,
@@ -116,6 +150,7 @@ CREATE TABLE IF NOT EXISTS sync_log (
CREATE TABLE IF NOT EXISTS active_tariffs (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
providerAccountId TEXT NOT NULL,
providerId TEXT NOT NULL,
externalId TEXT NOT NULL,
@@ -139,8 +174,17 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS tariff_sync_options (
providerAccountId TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
datacenters TEXT,
periods TEXT,
syncedAt TEXT
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
baseCurrency TEXT,
ratesUrl TEXT,
autoConvert INTEGER,
@@ -165,11 +209,13 @@ CREATE TABLE IF NOT EXISTS settings (
integrationToken TEXT,
integrationEnabled INTEGER,
integrationLastSyncAt TEXT,
cfdmApiUrl TEXT
cfdmApiUrl TEXT,
showQuickActions INTEGER
);
CREATE TABLE IF NOT EXISTS vps_domains (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
vpsId TEXT,
fqdn TEXT NOT NULL,
zoneName TEXT NOT NULL,
@@ -187,6 +233,7 @@ CREATE TABLE IF NOT EXISTS vps_domains (
CREATE TABLE IF NOT EXISTS notification_log (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
event TEXT NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL,
@@ -198,6 +245,7 @@ CREATE TABLE IF NOT EXISTS notification_log (
CREATE TABLE IF NOT EXISTS notification_state (
key TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
lastFingerprint TEXT,
lastSentAt TEXT,
lastStatus TEXT
@@ -205,12 +253,34 @@ CREATE TABLE IF NOT EXISTS notification_state (
CREATE TABLE IF NOT EXISTS server_projects (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
name TEXT NOT NULL,
color TEXT,
sortOrder INTEGER DEFAULT 0,
notes TEXT,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
entity TEXT NOT NULL,
entityId TEXT NOT NULL,
action TEXT NOT NULL,
diff TEXT,
actorUserId TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vps_health_checks (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
vpsId TEXT NOT NULL,
checkedAt TEXT NOT NULL,
status TEXT NOT NULL,
latencyMs INTEGER,
error TEXT
);
`
export function resetTestDb(): void {
@@ -219,13 +289,20 @@ export function resetTestDb(): void {
process.env.DB_PATH = ':memory:'
const sqlite = getSqlite()
sqlite.exec(TEST_SCHEMA)
const now = new Date().toISOString()
sqlite
.prepare(
`INSERT OR IGNORE INTO spaces (id, name, slug, kind, ownerUserId, createdAt)
VALUES ('space-main', 'Основное', 'main', 'main', NULL, ?)`,
)
.run(now)
}
export function seedTestProvider(id = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES (?, 'Test Host', 'billmanager', 'https://bm.test')`,
`INSERT INTO providers (id, spaceId, name, apiType, apiBaseUrl) VALUES (?, 'space-main', 'Test Host', 'billmanager', 'https://bm.test')`,
)
.run(id)
}
@@ -233,6 +310,8 @@ export function seedTestProvider(id = 'prov-1'): void {
export function seedTestProviderAccount(id = 'acc-1', providerId = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(`INSERT INTO provider_accounts (id, providerId, name) VALUES (?, ?, 'Test Account')`)
.prepare(
`INSERT INTO provider_accounts (id, spaceId, providerId, name) VALUES (?, 'space-main', ?, 'Test Account')`,
)
.run(id, providerId)
}