fix(services): восстанавливать урезанные общие FQDN без пересоздания сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 58s
CD / quality (push) Successful in 2m26s
CD / publish (push) Successful in 1m40s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 58s
CD / quality (push) Successful in 2m26s
CD / publish (push) Successful in 1m40s
Legacy toggle оставлял multi-IP привязки с неполным пулом в невидимом preserved — UI терял домены и падал на дубликатах. Чиним hydrate и чиним БД при buildView. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -345,7 +345,38 @@ async function collectKnownZones(
|
|||||||
return zones;
|
return zones;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
|
||||||
|
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
|
||||||
|
const pool = repos.listServiceIps(db, serviceId);
|
||||||
|
if (pool.length < 2) return;
|
||||||
|
const poolSet = new Set(pool);
|
||||||
|
|
||||||
|
for (const binding of repos.listBindingsByService(db, serviceId)) {
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
if (current.length <= 1) continue;
|
||||||
|
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
|
||||||
|
|
||||||
|
const currentSet = new Set(current.map((entry) => entry.ip));
|
||||||
|
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
|
||||||
|
repos.replaceBindingIpsWithMeta(
|
||||||
|
db,
|
||||||
|
binding.id,
|
||||||
|
pool.map((ip) => ({
|
||||||
|
ip,
|
||||||
|
weight: byIp.get(ip)?.weight ?? 1,
|
||||||
|
priority: byIp.get(ip)?.priority ?? 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||||
|
repairPoolSubsetBindings(db, serviceId);
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||||
const ips = ipRows.map((row) => row.ip);
|
const ips = ipRows.map((row) => row.ip);
|
||||||
|
|||||||
@@ -396,4 +396,111 @@ describe("create service then list groups", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"VPN",
|
||||||
|
"vpn-repair",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
name: "Repair",
|
||||||
|
slug: "panel-ip-repair",
|
||||||
|
service_group_id: group.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||||
|
service_group_id: group.id,
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gw.example.com",
|
||||||
|
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "extra.example.com",
|
||||||
|
target_ips: ["1.2.3.4"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonBinding = repos
|
||||||
|
.listBindingsByService(app.db, created.id)
|
||||||
|
.find((b) => b.hostname === "gw")!;
|
||||||
|
const extraBinding = repos
|
||||||
|
.listBindingsByService(app.db, created.id)
|
||||||
|
.find((b) => b.hostname === "extra")!;
|
||||||
|
|
||||||
|
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
||||||
|
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
||||||
|
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||||
|
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
||||||
|
"1.2.3.4",
|
||||||
|
"5.6.7.8",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${created.id}`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
const view = getRes.json() as {
|
||||||
|
domains: Array<{ fqdn: string; target_ips: string[] }>;
|
||||||
|
};
|
||||||
|
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
||||||
|
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
||||||
|
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
||||||
|
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
||||||
|
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
||||||
|
"1.2.3.4",
|
||||||
|
"5.6.7.8",
|
||||||
|
"9.9.9.9",
|
||||||
|
]);
|
||||||
|
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,6 +138,46 @@ describe('hydrateAddressBlock', () => {
|
|||||||
})
|
})
|
||||||
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
|
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
|
||||||
|
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
|
||||||
|
const drafts = [
|
||||||
|
// corrupted common — missing one pool IP
|
||||||
|
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
|
||||||
|
aRecord('gt.rkns.top', pool),
|
||||||
|
aRecord('nsgt.rkns.top', ['130.49.213.176']),
|
||||||
|
aRecord('rutg.rkns.top', ['93.115.203.183']),
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, pool)
|
||||||
|
|
||||||
|
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
|
||||||
|
expect(state.nodes).toEqual([
|
||||||
|
{ ip: '130.49.213.153', extraFqdns: [] },
|
||||||
|
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
|
||||||
|
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
|
||||||
|
])
|
||||||
|
expect(state.preservedBindings).toEqual([])
|
||||||
|
|
||||||
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||||
|
'gw.pngs.top',
|
||||||
|
'gt.rkns.top',
|
||||||
|
'nsgt.rkns.top',
|
||||||
|
'rutg.rkns.top',
|
||||||
|
])
|
||||||
|
expect(payload[0]?.target_ips).toEqual(pool)
|
||||||
|
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не дублирует FQDN при повторном binding в drafts', () => {
|
||||||
|
const pool = ['1.1.1.1', '2.2.2.2']
|
||||||
|
const state = hydrateAddressBlock(
|
||||||
|
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
|
||||||
|
pool,
|
||||||
|
)
|
||||||
|
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||||
|
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('toDomainsPayload', () => {
|
describe('toDomainsPayload', () => {
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean {
|
|||||||
return right.every((ip) => set.has(ip.trim()))
|
return right.every((ip) => set.has(ip.trim()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fqdnKey(value: string): string {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||||
return (service.domains ?? []).map((binding) => ({
|
return (service.domains ?? []).map((binding) => ({
|
||||||
fqdn: bindingToFqdn(binding),
|
fqdn: bindingToFqdn(binding),
|
||||||
@@ -145,14 +149,27 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
|||||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
|
||||||
|
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
|
||||||
|
if (draft.record_type !== 'A') return false
|
||||||
|
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
|
||||||
|
if (ips.length < 2) return false
|
||||||
|
return ips.every((ip) => poolSet.has(ip))
|
||||||
|
}
|
||||||
|
|
||||||
function takeAsCommon(
|
function takeAsCommon(
|
||||||
draft: ServiceBindingDraft,
|
draft: ServiceBindingDraft,
|
||||||
fqdn: string,
|
fqdn: string,
|
||||||
commonFqdns: string[],
|
commonFqdns: string[],
|
||||||
|
seenCommon: Set<string>,
|
||||||
weights: Record<string, number>,
|
weights: Record<string, number>,
|
||||||
priorities: Record<string, number>,
|
priorities: Record<string, number>,
|
||||||
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
||||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
const key = fqdnKey(fqdn)
|
||||||
|
if (fqdn && key && !seenCommon.has(key)) {
|
||||||
|
seenCommon.add(key)
|
||||||
|
commonFqdns.push(draft.fqdn)
|
||||||
|
}
|
||||||
if (Object.keys(weights).length === 0) {
|
if (Object.keys(weights).length === 0) {
|
||||||
return {
|
return {
|
||||||
weights: { ...draft.target_ip_weights },
|
weights: { ...draft.target_ip_weights },
|
||||||
@@ -178,29 +195,56 @@ export function hydrateAddressBlock(
|
|||||||
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
||||||
const poolSet = new Set(ips)
|
const poolSet = new Set(ips)
|
||||||
const commonFqdns: string[] = []
|
const commonFqdns: string[] = []
|
||||||
|
const seenCommon = new Set<string>()
|
||||||
|
const seenExtra = new Set<string>()
|
||||||
const extraByIp = new Map<string, string[]>()
|
const extraByIp = new Map<string, string[]>()
|
||||||
const preservedBindings: ServiceBindingDraft[] = []
|
const preservedBindings: ServiceBindingDraft[] = []
|
||||||
let weights: Record<string, number> = {}
|
let weights: Record<string, number> = {}
|
||||||
let priorities: Record<string, number> = {}
|
let priorities: Record<string, number> = {}
|
||||||
const splitSinglePool =
|
const splitSinglePool =
|
||||||
ips.length === 1 &&
|
ips.length === 1 &&
|
||||||
drafts.filter((draft) => isFullPoolA(draft, ips)).length > 1
|
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
|
||||||
|
.length > 1
|
||||||
let assignedFirstSinglePoolCommon = false
|
let assignedFirstSinglePoolCommon = false
|
||||||
|
|
||||||
function pushExtra(ip: string, fqdn: string) {
|
function pushExtra(ip: string, fqdn: string) {
|
||||||
|
const key = fqdnKey(fqdn)
|
||||||
|
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
|
||||||
|
seenExtra.add(key)
|
||||||
const list = extraByIp.get(ip) ?? []
|
const list = extraByIp.get(ip) ?? []
|
||||||
list.push(fqdn)
|
list.push(fqdn)
|
||||||
extraByIp.set(ip, list)
|
extraByIp.set(ip, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
|
||||||
|
const key = fqdnKey(fqdn)
|
||||||
|
if (key && seenExtra.has(key)) {
|
||||||
|
seenExtra.delete(key)
|
||||||
|
for (const [ip, list] of extraByIp) {
|
||||||
|
extraByIp.set(
|
||||||
|
ip,
|
||||||
|
list.filter((item) => fqdnKey(item) !== key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = takeAsCommon(
|
||||||
|
draft,
|
||||||
|
fqdn,
|
||||||
|
commonFqdns,
|
||||||
|
seenCommon,
|
||||||
|
weights,
|
||||||
|
priorities,
|
||||||
|
)
|
||||||
|
weights = next.weights
|
||||||
|
priorities = next.priorities
|
||||||
|
}
|
||||||
|
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
const fqdn = draft.fqdn.trim()
|
const fqdn = draft.fqdn.trim()
|
||||||
if (splitSinglePool && isFullPoolA(draft, ips)) {
|
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
|
||||||
if (!assignedFirstSinglePoolCommon) {
|
if (!assignedFirstSinglePoolCommon) {
|
||||||
assignedFirstSinglePoolCommon = true
|
assignedFirstSinglePoolCommon = true
|
||||||
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
promoteToCommon(draft, fqdn)
|
||||||
weights = next.weights
|
|
||||||
priorities = next.priorities
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||||
@@ -209,10 +253,9 @@ export function hydrateAddressBlock(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isFullPoolA(draft, ips)) {
|
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
|
||||||
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
|
||||||
weights = next.weights
|
promoteToCommon(draft, fqdn)
|
||||||
priorities = next.priorities
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||||
@@ -307,10 +350,6 @@ export function patchAddressIpMeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fqdnKey(value: string): string {
|
|
||||||
return value.trim().toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
||||||
const key = fqdnKey(fqdn)
|
const key = fqdnKey(fqdn)
|
||||||
if (!key) return false
|
if (!key) return false
|
||||||
@@ -318,13 +357,26 @@ export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean
|
|||||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
||||||
const trimmed = fqdn.trim()
|
const trimmed = fqdn.trim()
|
||||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
if (!trimmed) return state
|
||||||
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
|
const key = fqdnKey(trimmed)
|
||||||
|
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||||
|
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
|
||||||
|
const preservedBindings = state.preservedBindings.filter(
|
||||||
|
(item) => fqdnKey(item.fqdn) !== key,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
commonFqdns: [...state.commonFqdns, trimmed],
|
||||||
|
preservedBindings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
||||||
@@ -351,10 +403,16 @@ export function addExtraFqdn(
|
|||||||
fqdn: string,
|
fqdn: string,
|
||||||
): AddressBlockState {
|
): AddressBlockState {
|
||||||
const trimmed = fqdn.trim()
|
const trimmed = fqdn.trim()
|
||||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
if (!trimmed) return state
|
||||||
|
const key = fqdnKey(trimmed)
|
||||||
|
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||||
|
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
if (!state.nodes.some((node) => node.ip === ip)) return state
|
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
|
||||||
nodes: state.nodes.map((node) =>
|
nodes: state.nodes.map((node) =>
|
||||||
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
|
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
|
||||||
),
|
),
|
||||||
@@ -440,7 +498,13 @@ export function toAddressBindings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drafts.push(...state.preservedBindings)
|
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
|
||||||
|
for (const preserved of state.preservedBindings) {
|
||||||
|
const key = fqdnKey(preserved.fqdn)
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
drafts.push(preserved)
|
||||||
|
}
|
||||||
return drafts
|
return drafts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user