diff --git a/packages/db/src/repositories/ip-match.test.ts b/packages/db/src/repositories/ip-match.test.ts index 7f5ed78..a7c076a 100644 --- a/packages/db/src/repositories/ip-match.test.ts +++ b/packages/db/src/repositories/ip-match.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { collectVpsIps, findVpsIdByIps, + findVpsIdsByIps, isPrivateOrLoopbackIp, normalizeIp, } from './ip-match.js' @@ -31,6 +32,25 @@ describe('ip-match', () => { { id: 'b', ip: '', additionalIps: ['203.0.113.10'] }, ] expect(findVpsIdByIps(all, ['203.0.113.10'])).toBeNull() + expect(findVpsIdsByIps(all, ['203.0.113.10'])).toEqual([]) + }) + + it('собирает оба VPS по разным однозначным IP', () => { + const all = [ + { id: 'a', ip: '203.0.113.10', additionalIps: [] }, + { id: 'b', ip: '203.0.113.20', additionalIps: [] }, + ] + expect(findVpsIdByIps(all, ['203.0.113.10', '203.0.113.20'])).toBeNull() + expect(findVpsIdsByIps(all, ['203.0.113.10', '203.0.113.20']).sort()).toEqual(['a', 'b']) + }) + + it('пропускает неоднозначный IP и оставляет однозначные', () => { + const all = [ + { id: 'a', ip: '203.0.113.10', additionalIps: [] }, + { id: 'b', ip: '203.0.113.20', additionalIps: ['203.0.113.10'] }, + { id: 'c', ip: '198.51.100.1', additionalIps: [] }, + ] + expect(findVpsIdsByIps(all, ['203.0.113.10', '198.51.100.1'])).toEqual(['c']) }) it('считает loopback и RFC1918 приватными', () => { diff --git a/packages/db/src/repositories/ip-match.ts b/packages/db/src/repositories/ip-match.ts index 702b1bd..c5ec6ff 100644 --- a/packages/db/src/repositories/ip-match.ts +++ b/packages/db/src/repositories/ip-match.ts @@ -36,18 +36,24 @@ export function collectVpsIps(vps: { } export function findVpsIdByIps(allVps: T[], ips: string[]): string | null { - const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))] - if (normalized.length === 0) return null + const ids = findVpsIdsByIps(allVps, ips) + return ids.length === 1 ? ids[0]! : null +} - const matches: string[] = [] - for (const v of allVps) { - const vips = collectVpsIps(v) - if (normalized.some((ip) => vips.includes(ip))) { - matches.push(v.id) - } +/** + * Все VPS, у которых IP из списка однозначен (этот IP есть ровно у одного сервера). + * IP, висящий сразу на двух VPS, пропускается. + */ +export function findVpsIdsByIps(allVps: T[], ips: string[]): string[] { + const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))] + if (normalized.length === 0) return [] + + const result = new Set() + for (const ip of normalized) { + const matches = allVps.filter((v) => collectVpsIps(v).includes(ip)) + if (matches.length === 1) result.add(matches[0]!.id) } - if (matches.length === 1) return matches[0]! - return null + return [...result] } function ipv4Octets(ip: string): number[] | null { diff --git a/packages/db/src/repositories/vps-domains.test.ts b/packages/db/src/repositories/vps-domains.test.ts index 535e87c..d313fe6 100644 --- a/packages/db/src/repositories/vps-domains.test.ts +++ b/packages/db/src/repositories/vps-domains.test.ts @@ -147,6 +147,164 @@ describe('vpsDomainsRepository', () => { const domains = vpsDomainsRepository.listByVpsId(created.id) expect(domains.map((d) => d.fqdn).sort()).toEqual(['home.rkns.top', 'mhome.rkns.top']) }) + + it('привязывает общий FQDN ко всем VPS с однозначными IP', () => { + const vpsA = vpsRepository.create({ + ip: '203.0.113.40', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const vpsB = vpsRepository.create({ + ip: '203.0.113.41', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const a = Array.isArray(vpsA) ? vpsA[0]! : vpsA + const b = Array.isArray(vpsB) ? vpsB[0]! : vpsB + + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 6, + serviceId: 30, + serviceName: 'DNS', + serviceSlug: 'dns', + fqdn: 'dns.example.com', + zoneName: 'example.com', + hostname: 'dns', + ips: ['203.0.113.40', '203.0.113.41'], + }, + { + bindingId: 7, + serviceId: 30, + serviceName: 'DNS', + serviceSlug: 'dns', + fqdn: '*.dns.example.com', + zoneName: 'example.com', + hostname: '*.dns', + ips: ['203.0.113.40', '203.0.113.41'], + }, + ]) + + expect(result.matched).toBe(2) + expect(result.unmatched).toBe(0) + expect(result.upserted).toBe(4) + expect(vpsDomainsRepository.listUnmatched()).toHaveLength(0) + expect(vpsDomainsRepository.listByVpsId(a.id).map((d) => d.fqdn).sort()).toEqual([ + '*.dns.example.com', + 'dns.example.com', + ]) + expect(vpsDomainsRepository.listByVpsId(b.id).map((d) => d.fqdn).sort()).toEqual([ + '*.dns.example.com', + 'dns.example.com', + ]) + }) + + it('привязывает только VPS с совпавшим IP, если второй IP неизвестен', () => { + const vps = vpsRepository.create({ + ip: '203.0.113.50', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const created = Array.isArray(vps) ? vps[0]! : vps + + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 8, + serviceId: 31, + serviceName: 'DNS', + serviceSlug: 'dns', + fqdn: 'ns.example.com', + zoneName: 'example.com', + hostname: 'ns', + ips: ['203.0.113.50', '198.51.100.9'], + }, + ]) + + expect(result.matched).toBe(1) + expect(result.unmatched).toBe(0) + expect(vpsDomainsRepository.listByVpsId(created.id)).toHaveLength(1) + expect(vpsDomainsRepository.listUnmatched()).toHaveLength(0) + }) + + it('наследует все VPS родителя по CNAME', () => { + const vpsA = vpsRepository.create({ + ip: '203.0.113.60', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const vpsB = vpsRepository.create({ + ip: '203.0.113.61', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const a = Array.isArray(vpsA) ? vpsA[0]! : vpsA + const b = Array.isArray(vpsB) ? vpsB[0]! : vpsB + + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 9, + serviceId: 40, + serviceName: 'DNS', + serviceSlug: 'dns', + fqdn: 'dns.example.com', + zoneName: 'example.com', + hostname: 'dns', + ips: ['203.0.113.60', '203.0.113.61'], + }, + { + bindingId: 10, + serviceId: 41, + serviceName: 'Alias', + serviceSlug: 'alias', + fqdn: 'ns.other.example', + zoneName: 'other.example', + hostname: 'ns', + ips: [], + cnameTarget: 'dns.example.com', + }, + ]) + + expect(result.matched).toBe(2) + expect(result.unmatched).toBe(0) + expect(vpsDomainsRepository.listByVpsId(a.id).map((d) => d.fqdn).sort()).toEqual([ + 'dns.example.com', + 'ns.other.example', + ]) + expect(vpsDomainsRepository.listByVpsId(b.id).map((d) => d.fqdn).sort()).toEqual([ + 'dns.example.com', + 'ns.other.example', + ]) + }) }) describe('settingsRepository integration fields', () => { diff --git a/packages/db/src/repositories/vps-domains.ts b/packages/db/src/repositories/vps-domains.ts index 6ec9e71..21215da 100644 --- a/packages/db/src/repositories/vps-domains.ts +++ b/packages/db/src/repositories/vps-domains.ts @@ -1,13 +1,13 @@ 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 { findVpsIdByIps, isIpLiteral } from './ip-match.js' +import { findVpsIdsByIps, isIpLiteral } from './ip-match.js' import { vpsRepository } from './vps.js' type Row = typeof schema.vpsDomains.$inferSelect +type Db = ReturnType export type VpsDomainDto = Row @@ -27,33 +27,143 @@ function findVpsIdByDns( return null } -function findVpsIdForBinding( +function findVpsIdsForBinding( allVps: ReturnType, item: Pick, -): string | null { - return ( - findVpsIdByIps(allVps, item.ips) ?? - findVpsIdByDns(allVps, item.fqdn) ?? - (item.cnameTarget ? findVpsIdByDns(allVps, item.cnameTarget) : null) - ) +): string[] { + const byIps = findVpsIdsByIps(allVps, item.ips) + if (byIps.length > 0) return byIps + const byDns = findVpsIdByDns(allVps, item.fqdn) + if (byDns) return [byDns] + if (item.cnameTarget) { + const byCname = findVpsIdByDns(allVps, item.cnameTarget) + if (byCname) return [byCname] + } + return [] } -function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' { - return vpsId ? 'matched' : 'unmatched' +function parseStoredIps(raw: string | null | undefined): string[] { + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [] + } catch { + return [] + } } -function applyInheritedMatch( - db: ReturnType, - rowId: string, - vpsId: string, - counters: { matched: number; unmatched: number }, -): void { - db.update(schema.vpsDomains) - .set({ vpsId, matchStatus: 'matched' }) - .where(eq(schema.vpsDomains.id, rowId)) +type BindingFields = { + spaceId: string + fqdn: string + zoneName: string + hostname: string + serviceName: string + serviceSlug: string + cfdmServiceId: number + cfdmBindingId: number + source: 'cfdm' + targetIps: string + syncedAt: string +} + +function listByCfdmBindingId(bindingId: number): VpsDomainDto[] { + return getDb() + .select() + .from(schema.vpsDomains) + .where(eq(schema.vpsDomains.cfdmBindingId, bindingId)) + .all() +} + +function deleteAllByCfdmBindingId(bindingId: number): boolean { + const result = getDb() + .delete(schema.vpsDomains) + .where(eq(schema.vpsDomains.cfdmBindingId, bindingId)) .run() - counters.matched++ - counters.unmatched = Math.max(0, counters.unmatched - 1) + return result.changes > 0 +} + +/** + * Одна unmatched-строка (vpsId NULL) либо N matched-строк — по одной на VPS. + * Лишние строки binding удаляются. + */ +function reconcileBindingToVpsIds( + db: Db, + existing: VpsDomainDto[], + fields: BindingFields, + vpsIds: string[], + emptyStatus: 'unmatched' | 'orphaned' = 'unmatched', +): { upserted: number; deleted: number } { + let upserted = 0 + let deleted = 0 + const keepIds = new Set() + + if (vpsIds.length === 0) { + const reusable = existing.find((r) => !r.vpsId) ?? existing[0] + const values = { + ...fields, + vpsId: null, + matchStatus: emptyStatus, + } + if (reusable) { + db.update(schema.vpsDomains) + .set(values) + .where(eq(schema.vpsDomains.id, reusable.id)) + .run() + keepIds.add(reusable.id) + upserted++ + } else { + const id = generateId('vd') + db.insert(schema.vpsDomains).values({ id, ...values }).run() + keepIds.add(id) + upserted++ + } + } else { + const byVpsId = new Map() + for (const row of existing) { + if (row.vpsId && !byVpsId.has(row.vpsId)) byVpsId.set(row.vpsId, row) + } + const unmatchedPool = existing.filter((r) => !r.vpsId) + for (const vpsId of vpsIds) { + const values = { + ...fields, + vpsId, + matchStatus: 'matched' as const, + } + const row = byVpsId.get(vpsId) ?? unmatchedPool.shift() + if (row) { + db.update(schema.vpsDomains) + .set(values) + .where(eq(schema.vpsDomains.id, row.id)) + .run() + keepIds.add(row.id) + } else { + const id = generateId('vd') + db.insert(schema.vpsDomains).values({ id, ...values }).run() + keepIds.add(id) + } + upserted++ + } + } + + for (const row of existing) { + if (keepIds.has(row.id)) continue + db.delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run() + deleted++ + } + + return { upserted, deleted } +} + +function addToSetMap(map: Map>, key: string, ids: string[]): void { + const set = map.get(key) ?? new Set() + for (const id of ids) set.add(id) + map.set(key, set) +} + +function addServiceVps(map: Map>, serviceId: number, ids: string[]): void { + const set = map.get(serviceId) ?? new Set() + for (const id of ids) set.add(id) + map.set(serviceId, set) } export const vpsDomainsRepository = { @@ -77,18 +187,11 @@ export const vpsDomainsRepository = { }, getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined { - return getDb() - .select() - .from(schema.vpsDomains) - .where(eq(schema.vpsDomains.cfdmBindingId, bindingId)) - .get() + return listByCfdmBindingId(bindingId)[0] }, deleteByCfdmBindingId(bindingId: number): boolean { - const row = this.getByCfdmBindingId(bindingId) - if (!row) return false - getDb().delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run() - return true + return deleteAllByCfdmBindingId(bindingId) }, rematchAll(): { updated: number } { @@ -100,41 +203,50 @@ export const vpsDomainsRepository = { .from(schema.vpsDomains) .where(eq(schema.vpsDomains.spaceId, spaceId)) .all() - let updated = 0 - const vpsIds = new Set(allVps.map((v) => v.id)) - + const vpsIdSet = new Set(allVps.map((v) => v.id)) + const byBinding = new Map() for (const row of rows) { - let storedIps: string[] = [] - try { - storedIps = row.targetIps ? JSON.parse(row.targetIps) : [] - } catch { - storedIps = [] - } + const group = byBinding.get(row.cfdmBindingId) ?? [] + group.push(row) + byBinding.set(row.cfdmBindingId, group) + } - let vpsId = row.vpsId - if (vpsId && !vpsIds.has(vpsId)) { - vpsId = null + let updated = 0 + const now = new Date().toISOString() + for (const group of byBinding.values()) { + const seed = group[0]! + const storedIps = parseStoredIps(seed.targetIps) + let wanted = storedIps.length > 0 ? findVpsIdsByIps(allVps, storedIps) : [] + if (wanted.length === 0) { + const byDns = findVpsIdByDns(allVps, seed.fqdn) + if (byDns) wanted = [byDns] } - if (!vpsId && storedIps.length > 0) { - vpsId = findVpsIdByIps(allVps, storedIps) - } - if (!vpsId) { - vpsId = findVpsIdByDns(allVps, row.fqdn) - } - const matchStatus = - vpsId && vpsIds.has(vpsId) - ? 'matched' - : row.vpsId && !vpsIds.has(row.vpsId) - ? 'orphaned' - : resolveMatchStatus(vpsId) + wanted = wanted.filter((id) => vpsIdSet.has(id)) - if (vpsId !== row.vpsId || matchStatus !== row.matchStatus) { - db.update(schema.vpsDomains) - .set({ vpsId, matchStatus }) - .where(eq(schema.vpsDomains.id, row.id)) - .run() - updated++ + const hadMissingVps = group.some((r) => r.vpsId && !vpsIdSet.has(r.vpsId)) + const emptyStatus: 'unmatched' | 'orphaned' = + wanted.length === 0 && hadMissingVps ? 'orphaned' : 'unmatched' + + const before = new Set(group.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`)) + const fields: BindingFields = { + spaceId, + fqdn: seed.fqdn, + zoneName: seed.zoneName, + hostname: seed.hostname, + serviceName: seed.serviceName, + serviceSlug: seed.serviceSlug, + cfdmServiceId: seed.cfdmServiceId, + cfdmBindingId: seed.cfdmBindingId, + source: 'cfdm', + targetIps: seed.targetIps ?? JSON.stringify(storedIps), + syncedAt: now, } + const result = reconcileBindingToVpsIds(db, group, fields, wanted, emptyStatus) + const afterRows = listByCfdmBindingId(seed.cfdmBindingId) + const after = new Set(afterRows.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`)) + const same = + before.size === after.size && [...before].every((k) => after.has(k)) + if (!same) updated += result.upserted + result.deleted } return { updated } }, @@ -152,94 +264,90 @@ export const vpsDomainsRepository = { const spaceId = getCurrentSpaceId() const allVps = vpsRepository.list() const now = new Date().toISOString() - let matched = 0 - let unmatched = 0 let deleted = 0 let upserted = 0 const keptBindingIds = new Set() - const fqdnToVpsId = new Map() + const fqdnToVpsIds = new Map>() const serviceMatchedVps = new Map>() const counters = { matched: 0, unmatched: 0 } + const fieldsOf = (item: CfdmBindingSyncItem): BindingFields => ({ + spaceId, + fqdn: item.fqdn, + zoneName: item.zoneName, + hostname: item.hostname, + serviceName: item.serviceName, + serviceSlug: item.serviceSlug, + cfdmServiceId: item.serviceId, + cfdmBindingId: item.bindingId, + source: 'cfdm', + targetIps: JSON.stringify(item.ips.filter(isIpLiteral)), + syncedAt: now, + }) + for (const item of items) { if (item.deleted) { - if (this.deleteByCfdmBindingId(item.bindingId)) deleted++ + if (deleteAllByCfdmBindingId(item.bindingId)) deleted++ continue } keptBindingIds.add(item.bindingId) - - const vpsId = findVpsIdForBinding(allVps, item) - const matchStatus = resolveMatchStatus(vpsId) - if (matchStatus === 'matched') { + const vpsIds = findVpsIdsForBinding(allVps, item) + if (vpsIds.length > 0) { counters.matched++ - fqdnToVpsId.set(normalizeHost(item.fqdn), vpsId!) - const set = serviceMatchedVps.get(item.serviceId) ?? new Set() - set.add(vpsId!) - serviceMatchedVps.set(item.serviceId, set) + addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), vpsIds) + addServiceVps(serviceMatchedVps, item.serviceId, vpsIds) } else { counters.unmatched++ } - const existing = this.getByCfdmBindingId(item.bindingId) - const values = { - spaceId, - vpsId, - fqdn: item.fqdn, - zoneName: item.zoneName, - hostname: item.hostname, - serviceName: item.serviceName, - serviceSlug: item.serviceSlug, - cfdmServiceId: item.serviceId, - cfdmBindingId: item.bindingId, - source: 'cfdm' as const, - matchStatus, - targetIps: JSON.stringify(item.ips.filter(isIpLiteral)), - syncedAt: now, - } - - if (existing) { - db.update(schema.vpsDomains).set(values).where(eq(schema.vpsDomains.id, existing.id)).run() - } else { - db.insert(schema.vpsDomains).values({ id: generateId('vd'), ...values }).run() - } - upserted++ + const result = reconcileBindingToVpsIds(db, listByCfdmBindingId(item.bindingId), fieldsOf(item), vpsIds) + upserted += result.upserted + deleted += result.deleted } - // CNAME → уже matched FQDN (например imsk → ihome) let inherited = true while (inherited) { inherited = false for (const item of items) { if (item.deleted || !item.cnameTarget) continue - const existing = this.getByCfdmBindingId(item.bindingId) - if (!existing || existing.vpsId) continue - const parentVpsId = fqdnToVpsId.get(normalizeHost(item.cnameTarget)) - if (!parentVpsId) continue - applyInheritedMatch(db, existing.id, parentVpsId, counters) - fqdnToVpsId.set(normalizeHost(item.fqdn), parentVpsId) - const set = serviceMatchedVps.get(item.serviceId) ?? new Set() - set.add(parentVpsId) - serviceMatchedVps.set(item.serviceId, set) + const existing = listByCfdmBindingId(item.bindingId) + if (existing.some((r) => r.vpsId)) continue + const parentIds = fqdnToVpsIds.get(normalizeHost(item.cnameTarget)) + if (!parentIds || parentIds.size === 0) continue + const ids = [...parentIds] + const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId) + const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), ids) + upserted += result.upserted + deleted += result.deleted + addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), ids) + addServiceVps(serviceMatchedVps, item.serviceId, ids) + if (wasUnmatched) { + counters.matched++ + counters.unmatched = Math.max(0, counters.unmatched - 1) + } inherited = true } } - // Sibling bindings того же CFDM-сервиса → один уникальный VPS for (const item of items) { if (item.deleted) continue - const existing = this.getByCfdmBindingId(item.bindingId) - if (!existing || existing.vpsId) continue + const existing = listByCfdmBindingId(item.bindingId) + if (existing.some((r) => r.vpsId)) continue const matched = serviceMatchedVps.get(item.serviceId) if (!matched || matched.size !== 1) continue const siblingVpsId = [...matched][0]! - applyInheritedMatch(db, existing.id, siblingVpsId, counters) - fqdnToVpsId.set(normalizeHost(item.fqdn), siblingVpsId) + const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId) + const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), [siblingVpsId]) + upserted += result.upserted + deleted += result.deleted + addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), [siblingVpsId]) + if (wasUnmatched) { + counters.matched++ + counters.unmatched = Math.max(0, counters.unmatched - 1) + } } - matched = counters.matched - unmatched = counters.unmatched - if (opts?.fullSync) { const rows = db .select() @@ -259,7 +367,12 @@ export const vpsDomainsRepository = { } } - return { matched, unmatched, deleted, upserted } + return { + matched: counters.matched, + unmatched: counters.unmatched, + deleted, + upserted, + } }, markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number { diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 4eb8ba4..66d4dc6 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -258,11 +258,12 @@ const CORE_TABLE_MIGRATIONS: string[] = [ serviceName TEXT NOT NULL, serviceSlug TEXT NOT NULL, cfdmServiceId INTEGER NOT NULL, - cfdmBindingId INTEGER NOT NULL UNIQUE, + cfdmBindingId INTEGER NOT NULL, source TEXT NOT NULL DEFAULT 'cfdm', matchStatus TEXT NOT NULL DEFAULT 'unmatched', targetIps TEXT, - syncedAt TEXT NOT NULL + syncedAt TEXT NOT NULL, + UNIQUE(cfdmBindingId, vpsId) )`, `CREATE TABLE IF NOT EXISTS vps_grants ( id TEXT PRIMARY KEY, @@ -462,6 +463,54 @@ function backfillSpaceIds(sqlite: Database.Database): void { } } +function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void { + const row = sqlite + .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'vps_domains'`) + .get() as { sql?: string } | undefined + const sql = row?.sql ?? '' + if (!sql) return + if (sql.includes('UNIQUE(cfdmBindingId, vpsId)') || sql.includes('UNIQUE (cfdmBindingId, vpsId)')) { + return + } + + sqlite.exec('PRAGMA foreign_keys = OFF') + sqlite.exec('BEGIN') + try { + sqlite.exec(`CREATE TABLE vps_domains_new ( + id TEXT PRIMARY KEY, + spaceId TEXT NOT NULL DEFAULT 'space-main', + vpsId TEXT REFERENCES vps(id) ON DELETE SET NULL, + fqdn TEXT NOT NULL, + zoneName TEXT NOT NULL, + hostname TEXT NOT NULL, + serviceName TEXT NOT NULL, + serviceSlug TEXT NOT NULL, + cfdmServiceId INTEGER NOT NULL, + cfdmBindingId INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'cfdm', + matchStatus TEXT NOT NULL DEFAULT 'unmatched', + targetIps TEXT, + syncedAt TEXT NOT NULL, + UNIQUE(cfdmBindingId, vpsId) + )`) + sqlite.exec(`INSERT INTO vps_domains_new ( + id, spaceId, vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug, + cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt + ) SELECT + id, COALESCE(spaceId, 'space-main'), vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug, + cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt + FROM vps_domains`) + sqlite.exec('DROP TABLE vps_domains') + sqlite.exec('ALTER TABLE vps_domains_new RENAME TO vps_domains') + sqlite.exec('COMMIT') + } catch (err) { + sqlite.exec('ROLLBACK') + throw err + } finally { + sqlite.exec('PRAGMA foreign_keys = ON') + } +} + let migrated = false export function resetRuntimeMigrate(): void { @@ -480,6 +529,7 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void { /* column exists */ } } + rebuildVpsDomainsBindingUnique(sqlite) ensureMainSpace(sqlite) backfillSpaceIds(sqlite) migrated = true diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 520deda..530c01d 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -213,25 +213,31 @@ export const settings = sqliteTable('settings', { showQuickActions: integer('showQuickActions'), }) -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(), - hostname: text('hostname').notNull(), - serviceName: text('serviceName').notNull(), - serviceSlug: text('serviceSlug').notNull(), - cfdmServiceId: integer('cfdmServiceId').notNull(), - cfdmBindingId: integer('cfdmBindingId').notNull(), - source: text('source').notNull().default('cfdm'), - matchStatus: text('matchStatus').notNull().default('unmatched'), - targetIps: text('targetIps'), - syncedAt: text('syncedAt').notNull(), -}) +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(), + hostname: text('hostname').notNull(), + serviceName: text('serviceName').notNull(), + serviceSlug: text('serviceSlug').notNull(), + cfdmServiceId: integer('cfdmServiceId').notNull(), + cfdmBindingId: integer('cfdmBindingId').notNull(), + source: text('source').notNull().default('cfdm'), + matchStatus: text('matchStatus').notNull().default('unmatched'), + targetIps: text('targetIps'), + syncedAt: text('syncedAt').notNull(), + }, + (t) => ({ + bindingVpsUniq: uniqueIndex('vps_domains_binding_vps').on(t.cfdmBindingId, t.vpsId), + }), +) export const notificationLog = sqliteTable('notification_log', { id: text('id').primaryKey(), diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index 0d43393..ef7c5b1 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -225,11 +225,12 @@ CREATE TABLE IF NOT EXISTS vps_domains ( serviceName TEXT NOT NULL, serviceSlug TEXT NOT NULL, cfdmServiceId INTEGER NOT NULL, - cfdmBindingId INTEGER NOT NULL UNIQUE, + cfdmBindingId INTEGER NOT NULL, source TEXT NOT NULL DEFAULT 'cfdm', matchStatus TEXT NOT NULL DEFAULT 'unmatched', targetIps TEXT, syncedAt TEXT NOT NULL, + UNIQUE(cfdmBindingId, vpsId), FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL );