Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
585 lines
20 KiB
JavaScript
585 lines
20 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { randomUUID } from "node:crypto";
|
||
import { readFile, readdir } from "node:fs/promises";
|
||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||
const db = new PGlite();
|
||
try {
|
||
await db.exec(`create role anon;create role authenticated;create role service_role;
|
||
create schema auth;create table auth.users(id uuid primary key);
|
||
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
|
||
create schema storage;create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
|
||
create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
|
||
create function storage.foldername(text) returns text[] language sql as $$select string_to_array($1,'/')$$;`);
|
||
for (const file of (await readdir("supabase/migrations"))
|
||
.filter((f) => f.endsWith(".sql"))
|
||
.sort())
|
||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||
const q = async (sql, args = []) => (await db.query(sql, args)).rows;
|
||
const users = [];
|
||
for (let i = 0; i < 34; i++) {
|
||
const id = randomUUID();
|
||
users.push(id);
|
||
await q("insert into auth.users values($1)", [id]);
|
||
await q(
|
||
"insert into public.profiles(id,display_name) values($1,$2) on conflict(id) do update set display_name=excluded.display_name",
|
||
[id, `Player ${i}`],
|
||
);
|
||
}
|
||
const map = (
|
||
await q(
|
||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||
values($1,'Tournament fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
|
||
[users[0]],
|
||
)
|
||
)[0].id;
|
||
const manifest = {
|
||
name: "Tournament fixture",
|
||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||
id: `p${i}`,
|
||
x: 10 + i,
|
||
y: 64,
|
||
z: 10 + i,
|
||
})),
|
||
};
|
||
const rev = (
|
||
await q(
|
||
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
|
||
[map, JSON.stringify(manifest)],
|
||
)
|
||
)[0].id;
|
||
await q("update public.datasets set revision_id=$1 where id=$2", [rev, map]);
|
||
const base = {
|
||
name: "Community Cup",
|
||
datasetId: map,
|
||
difficulty: "normal",
|
||
playerLimit: 32,
|
||
roundCount: 2,
|
||
roundTimeSeconds: 30,
|
||
timerMode: "perMini",
|
||
startsAt: new Date(Date.now() + 3600000).toISOString(),
|
||
serverTimezone: "UTC",
|
||
announceDiscord: true,
|
||
};
|
||
const create = async (payload = {}) =>
|
||
(
|
||
await q("select public.create_tournament($1,$2) id", [
|
||
users[0],
|
||
JSON.stringify({ ...base, ...payload }),
|
||
])
|
||
)[0].id;
|
||
const voidEvent = await create({
|
||
difficulty: "void",
|
||
roundTimeSeconds: 10,
|
||
timerMode: "perMini",
|
||
});
|
||
const voidRules = (
|
||
await q(
|
||
"select difficulty_settings,round_time_seconds,timer_mode from public.tournaments where id=$1",
|
||
[voidEvent],
|
||
)
|
||
)[0];
|
||
assert.equal(voidRules.difficulty_settings.seconds, 10);
|
||
assert.equal(voidRules.difficulty_settings.mapPan, false);
|
||
assert.equal(voidRules.difficulty_settings.scoring, "strict");
|
||
assert.equal(voidRules.round_time_seconds, 10);
|
||
assert.equal(voidRules.timer_mode, "perMini");
|
||
for (const settings of [
|
||
{ roundTimeSeconds: 90, timerMode: "perMini" },
|
||
{ roundTimeSeconds: 10, timerMode: "shared" },
|
||
])
|
||
await assert.rejects(
|
||
create({ difficulty: "void", ...settings }),
|
||
/Void requires a separate 10-second timer/,
|
||
);
|
||
await q("delete from public.tournaments where id=$1", [voidEvent]);
|
||
const act = async (id, user, action = "view", payload = {}) =>
|
||
(
|
||
await q("select public.tournament_action($1,$2,$3,$4) value", [
|
||
id,
|
||
user,
|
||
action,
|
||
JSON.stringify(payload),
|
||
])
|
||
)[0].value;
|
||
for (const payload of [
|
||
{ playerLimit: 33 },
|
||
{ playerLimit: 1 },
|
||
{ roundCount: 0 },
|
||
{ roundTimeSeconds: 16 },
|
||
{ serverTimezone: "Invalid/Zone" },
|
||
{ startsAt: new Date(0).toISOString() },
|
||
])
|
||
await assert.rejects(create(payload));
|
||
await assert.rejects(
|
||
q("select public.create_tournament(null,$1)", [JSON.stringify(base)]),
|
||
/Active account/,
|
||
);
|
||
const tid = await create();
|
||
for (let i = 1; i < 32; i++) await act(tid, users[i], "join");
|
||
assert.equal((await act(tid, users[1], "join")).players.length, 32);
|
||
await assert.rejects(act(tid, users[32], "join"), /full/);
|
||
await assert.rejects(act(tid, users[1], "edit", {}), /owner/);
|
||
await assert.rejects(
|
||
act(tid, users[0], "edit", { playerLimit: 31 }),
|
||
/exclude/,
|
||
);
|
||
await assert.rejects(act(tid, users[0], "start"), /scheduled start/);
|
||
const worker = randomUUID();
|
||
assert.equal(
|
||
(
|
||
await q("select * from public.claim_tournament_announcements($1,$2)", [
|
||
worker,
|
||
tid,
|
||
])
|
||
).length,
|
||
0,
|
||
);
|
||
await act(tid, users[0], "edit", {
|
||
startsAt: new Date(Date.now() + 14 * 60000).toISOString(),
|
||
});
|
||
assert.equal(
|
||
(
|
||
await q("select * from public.claim_tournament_announcements($1,$2)", [
|
||
worker,
|
||
tid,
|
||
])
|
||
).length,
|
||
1,
|
||
);
|
||
assert.equal(
|
||
(
|
||
await q("select * from public.claim_tournament_announcements($1,$2)", [
|
||
randomUUID(),
|
||
tid,
|
||
])
|
||
).length,
|
||
0,
|
||
);
|
||
await q(
|
||
"update public.tournament_announcements set attempts=8,lease_until=clock_timestamp()-interval '1 second' where tournament_id=$1",
|
||
[tid],
|
||
);
|
||
assert.equal(
|
||
(
|
||
await q("select * from public.claim_tournament_announcements($1,$2)", [
|
||
worker,
|
||
tid,
|
||
])
|
||
).length,
|
||
0,
|
||
);
|
||
assert.equal(
|
||
(
|
||
await q(
|
||
"select state from public.tournament_announcements where tournament_id=$1",
|
||
[tid],
|
||
)
|
||
)[0].state,
|
||
"failed",
|
||
);
|
||
await act(tid, users[0], "edit", {
|
||
startsAt: new Date(Date.now() + 13 * 60000).toISOString(),
|
||
});
|
||
assert.equal(
|
||
(
|
||
await q("select * from public.claim_tournament_announcements($1,$2)", [
|
||
worker,
|
||
tid,
|
||
])
|
||
).length,
|
||
1,
|
||
);
|
||
await q(
|
||
"update public.tournament_announcements set lease_until=clock_timestamp()-interval '1 second' where tournament_id=$1",
|
||
[tid],
|
||
);
|
||
assert.equal(
|
||
(
|
||
await q("select * from public.claim_tournament_announcements($1,$2)", [
|
||
worker,
|
||
tid,
|
||
])
|
||
).length,
|
||
1,
|
||
);
|
||
await act(tid, users[0], "edit", { announceDiscord: false });
|
||
assert.equal(
|
||
(
|
||
await q(
|
||
"select state from public.tournament_announcements where tournament_id=$1",
|
||
[tid],
|
||
)
|
||
)[0].state,
|
||
"skipped",
|
||
);
|
||
await q(
|
||
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[tid],
|
||
);
|
||
await q("select public.tick_tournaments()");
|
||
let state = await act(tid, users[0]);
|
||
assert.equal(state.status, "running");
|
||
assert.equal(state.currentRoom.players.length, 32);
|
||
assert.equal(state.currentRoom.rounds.length, 5);
|
||
assert.equal(state.currentRoom.difficultySettings.seconds, 30);
|
||
assert.equal(state.currentRoom.difficultySettings.roundCount, 5);
|
||
assert.equal(state.announcementState, "skipped");
|
||
assert.equal((await act(tid, users[1])).announcementState, null);
|
||
assert(!JSON.stringify(state).includes("viewpoint"));
|
||
for (const row of await q(
|
||
"select access_hash from public.tournament_members where tournament_id=$1",
|
||
[tid],
|
||
))
|
||
assert(!JSON.stringify(state).includes(row.access_hash));
|
||
assert.equal((await act(tid, null)).currentRoom, null);
|
||
await assert.rejects(act(tid, users[32], "join"), /Registration/);
|
||
await assert.rejects(
|
||
q(
|
||
"select public.multiplayer_action($1,$2,'join','{\"name\":\"Intruder\"}')",
|
||
[state.roomId, "f".repeat(64)],
|
||
),
|
||
/already started/,
|
||
);
|
||
await assert.rejects(
|
||
q(
|
||
"insert into public.multiplayer_players(room_id,access_hash,display_name,account_id) values($1,$2,'Intruder',$3)",
|
||
[state.roomId, "f".repeat(64), users[32]],
|
||
),
|
||
/Tournament membership/,
|
||
);
|
||
await assert.rejects(
|
||
q("select public.create_map_room($1,'normal','Host',32,$2,$3)", [
|
||
map,
|
||
"a".repeat(64),
|
||
users[0],
|
||
]),
|
||
/16/,
|
||
);
|
||
// Play all ten server-scored mini rounds; the owner wins perfect guesses.
|
||
for (let outer = 0; outer < 2; outer++) {
|
||
state = await act(tid, users[0]);
|
||
const room = state.roomId;
|
||
for (let mini = 0; mini < 5; mini++) {
|
||
await q(
|
||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[room],
|
||
);
|
||
await q("select public.tournament_tick($1)", [tid]);
|
||
await q(
|
||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1 and status='loading'",
|
||
[room],
|
||
);
|
||
await q("select public.tournament_tick($1)", [tid]);
|
||
const answer = (
|
||
await q(
|
||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=$2",
|
||
[room, mini],
|
||
)
|
||
)[0].viewpoint;
|
||
await assert.rejects(
|
||
act(tid, users[0], "guess", {
|
||
round: mini,
|
||
tournamentRound: outer - 1,
|
||
x: answer.x,
|
||
z: answer.z,
|
||
}),
|
||
/round changed/,
|
||
);
|
||
state = await act(tid, users[0], "guess", {
|
||
round: mini,
|
||
tournamentRound: outer,
|
||
x: answer.x,
|
||
z: answer.z,
|
||
score: 999999,
|
||
});
|
||
assert.equal(state.players[0].total, outer * 25000 + mini * 5000); // open round remains private/unscored
|
||
for (let i = 1; i < 32; i++)
|
||
state = await act(tid, users[i], "guess", {
|
||
round: mini,
|
||
tournamentRound: outer,
|
||
x: 64,
|
||
z: 64,
|
||
score: 999999,
|
||
});
|
||
}
|
||
}
|
||
state = await act(tid, users[0]);
|
||
assert.equal(state.status, "finished");
|
||
assert.equal(state.winnerId, users[0]);
|
||
assert.equal(state.players[0].total, 50000);
|
||
const finishedAt = state.finishedAt;
|
||
await q("select public.tick_tournaments()");
|
||
assert.equal((await act(tid, users[0])).finishedAt, finishedAt);
|
||
const board = (await q("select public.player_leaderboard() value"))[0].value;
|
||
assert.equal(board.entries[0].player_id, users[0]);
|
||
assert.equal(board.entries[0].rank, 1);
|
||
assert.equal(board.entries[0].best_score, 0);
|
||
assert.equal(board.entries[0].tournament_champion.tournament_id, tid);
|
||
assert.equal(
|
||
(await q("select public.player_leaderboard('normal') value"))[0].value
|
||
.entries.length,
|
||
0,
|
||
);
|
||
assert.equal(
|
||
(await q("select public.player_leaderboard('all','nonexistent') value"))[0]
|
||
.value.matching_players,
|
||
0,
|
||
);
|
||
// Solo scores remain unchanged; winner insertion/de-duplication preserves pagination.
|
||
for (let i = 0; i < 3; i++) {
|
||
const sid = (
|
||
await q("select public.create_game($1,'normal',$2,$3) id", [
|
||
map,
|
||
users[i],
|
||
String(i).repeat(64),
|
||
])
|
||
)[0].id;
|
||
await q("update public.game_sessions set current_round=5 where id=$1", [
|
||
sid,
|
||
]);
|
||
await q("update public.game_rounds set result=$2 where session_id=$1", [
|
||
sid,
|
||
JSON.stringify({ score: 1000 + i }),
|
||
]);
|
||
}
|
||
let ranked = (await q("select public.player_leaderboard() value"))[0].value;
|
||
assert.equal(ranked.total_players, 3);
|
||
assert.equal(ranked.entries[0].player_id, users[0]);
|
||
assert.equal(ranked.entries[0].best_score, 5000);
|
||
const secondPage = (
|
||
await q("select public.player_leaderboard('all','',2,2) value")
|
||
)[0].value;
|
||
assert.equal(secondPage.pages, 2);
|
||
assert.equal(secondPage.entries.length, 1);
|
||
assert.equal(secondPage.entries[0].rank, 3);
|
||
const newer = await create({ announceDiscord: false });
|
||
await q(
|
||
"update public.tournaments set status='finished',finished_at=clock_timestamp(),winner_id=$2 where id=$1",
|
||
[newer, users[1]],
|
||
);
|
||
ranked = (await q("select public.player_leaderboard() value"))[0].value;
|
||
assert.equal(ranked.entries[0].player_id, users[1]);
|
||
assert.equal(ranked.entries[0].tournament_champion.tournament_id, newer);
|
||
assert.equal(
|
||
ranked.entries.filter((p) => p.tournament_champion !== null).length,
|
||
1,
|
||
);
|
||
await q(
|
||
"update public.tournaments set finished_at=clock_timestamp()-interval '8 days' where id=$1",
|
||
[tid],
|
||
);
|
||
await q(
|
||
"update public.tournaments set finished_at=clock_timestamp()-interval '8 days' where id=$1",
|
||
[newer],
|
||
);
|
||
ranked = (await q("select public.player_leaderboard() value"))[0].value;
|
||
assert.equal(ranked.entries[0].player_id, users[2]);
|
||
assert(ranked.entries.every((p) => p.tournament_champion === null));
|
||
for (const role of ["anon", "authenticated"])
|
||
for (const table of [
|
||
"tournaments",
|
||
"tournament_members",
|
||
"tournament_rounds",
|
||
"tournament_announcements",
|
||
])
|
||
assert.equal(
|
||
(
|
||
await q("select has_table_privilege($1,$2,'SELECT') allowed", [
|
||
role,
|
||
`public.${table}`,
|
||
])
|
||
)[0].allowed,
|
||
false,
|
||
);
|
||
for (const role of ["anon", "authenticated"])
|
||
assert.equal(
|
||
(
|
||
await q(
|
||
"select has_function_privilege($1,'public.tournament_action(uuid,uuid,text,jsonb)','EXECUTE') allowed",
|
||
[role],
|
||
)
|
||
)[0].allowed,
|
||
false,
|
||
);
|
||
for (const fn of await q(
|
||
"select proname,prosecdef,proconfig from pg_catalog.pg_proc p join pg_catalog.pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and (p.proname like '%tournament%' or p.proname='player_leaderboard')",
|
||
)) {
|
||
assert.equal(fn.prosecdef, false, fn.proname);
|
||
assert(
|
||
fn.proconfig.some((setting) => setting.startsWith("search_path=")),
|
||
fn.proname,
|
||
);
|
||
}
|
||
const alone = await create({ announceDiscord: false });
|
||
await q(
|
||
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[alone],
|
||
);
|
||
assert.equal((await act(alone, users[0])).status, "cancelled");
|
||
// A shared round clock starts after loading and caps subsequent mini rounds.
|
||
const shared = await create({
|
||
timerMode: "shared",
|
||
roundCount: 1,
|
||
roundTimeSeconds: 90,
|
||
announceDiscord: false,
|
||
});
|
||
await act(shared, users[1], "join");
|
||
await q(
|
||
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[shared],
|
||
);
|
||
let sharedState = await act(shared, users[0]);
|
||
assert.equal(sharedState.timerMode, "shared");
|
||
assert.equal(sharedState.roundDeadline, null);
|
||
await act(shared, users[0], "ready", { round: 0, tournamentRound: 0 });
|
||
sharedState = await act(shared, users[1], "ready", {
|
||
round: 0,
|
||
tournamentRound: 0,
|
||
});
|
||
const sharedDeadline = sharedState.roundDeadline;
|
||
assert(sharedDeadline);
|
||
assert.equal(sharedState.currentRoom.status, "playing");
|
||
const firstAnswer = (
|
||
await q(
|
||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
|
||
[sharedState.roomId],
|
||
)
|
||
)[0].viewpoint;
|
||
for (const user of users.slice(0, 2))
|
||
await act(shared, user, "guess", {
|
||
round: 0,
|
||
tournamentRound: 0,
|
||
x: firstAnswer.x,
|
||
z: firstAnswer.z,
|
||
});
|
||
await q(
|
||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[sharedState.roomId],
|
||
);
|
||
await act(shared, users[0], "ready", { round: 1, tournamentRound: 0 });
|
||
sharedState = await act(shared, users[1], "ready", {
|
||
round: 1,
|
||
tournamentRound: 0,
|
||
});
|
||
assert.equal(sharedState.roundDeadline, sharedDeadline);
|
||
assert.equal(sharedState.currentRoom.rounds[1].deadline, sharedDeadline);
|
||
await q(
|
||
"update public.tournament_rounds set deadline=clock_timestamp()-interval '1 second' where tournament_id=$1",
|
||
[shared],
|
||
);
|
||
await assert.rejects(
|
||
act(shared, users[0], "guess", {
|
||
round: 1,
|
||
tournamentRound: 0,
|
||
x: 64,
|
||
z: 64,
|
||
score: 999999,
|
||
}),
|
||
);
|
||
// An invalid action rolls back its transaction, so a normal poll settles it.
|
||
sharedState = await act(shared, users[0]);
|
||
assert.equal(sharedState.status, "finished");
|
||
assert.equal(sharedState.players[0].total, 5000);
|
||
assert.equal(
|
||
sharedState.currentRoom.rounds.filter((r) => r.closedAt !== null).length,
|
||
5,
|
||
);
|
||
assert(
|
||
sharedState.currentRoom.rounds
|
||
.slice(1)
|
||
.every((r) =>
|
||
r.results.every((result) => result.score === 0 && result.timedOut),
|
||
),
|
||
);
|
||
await assert.rejects(create({ timerMode: "perMini", roundTimeSeconds: 615 }));
|
||
await assert.rejects(create({ timerMode: "shared", roundTimeSeconds: 3015 }));
|
||
const bigShared = await create({
|
||
timerMode: "shared",
|
||
roundTimeSeconds: 3000,
|
||
announceDiscord: false,
|
||
});
|
||
assert.equal(
|
||
(await act(bigShared, users[0])).difficultySettings.seconds,
|
||
600,
|
||
);
|
||
await act(bigShared, users[1], "join");
|
||
await q(
|
||
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[bigShared],
|
||
);
|
||
await act(bigShared, users[0], "ready", { round: 0, tournamentRound: 0 });
|
||
let bigState = await act(bigShared, users[1], "ready", {
|
||
round: 0,
|
||
tournamentRound: 0,
|
||
});
|
||
const bigDeadline = bigState.roundDeadline;
|
||
assert(bigDeadline);
|
||
assert.equal(bigState.currentRoom.rounds[0].deadline, bigDeadline);
|
||
assert(Date.parse(bigDeadline) - bigState.serverNow > 2990 * 1000);
|
||
// The ordinary engine's 600-second mini timer must never cut a shared budget short.
|
||
await q(
|
||
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=0",
|
||
[bigState.roomId],
|
||
);
|
||
bigState = await act(bigShared, users[0]);
|
||
assert.equal(bigState.currentRoom.status, "playing");
|
||
assert.equal(bigState.roundDeadline, bigDeadline);
|
||
assert.equal(bigState.currentRoom.rounds[0].deadline, bigDeadline);
|
||
for (const user of users.slice(0, 2))
|
||
await act(bigShared, user, "guess", {
|
||
round: 0,
|
||
tournamentRound: 0,
|
||
x: 10,
|
||
z: 10,
|
||
});
|
||
await q(
|
||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[bigState.roomId],
|
||
);
|
||
await act(bigShared, users[0], "ready", { round: 1, tournamentRound: 0 });
|
||
bigState = await act(bigShared, users[1], "ready", {
|
||
round: 1,
|
||
tournamentRound: 0,
|
||
});
|
||
assert.equal(bigState.roundDeadline, bigDeadline);
|
||
assert.equal(bigState.currentRoom.rounds[1].deadline, bigDeadline);
|
||
const moderated = await create({ announceDiscord: false });
|
||
await act(moderated, users[1], "join");
|
||
await q("update public.datasets set state='draft' where id=$1", [map]);
|
||
assert.equal((await act(moderated, users[0])).status, "cancelled");
|
||
await q("update public.datasets set state='published' where id=$1", [map]);
|
||
const abandoned = await create({ announceDiscord: false });
|
||
await act(abandoned, users[1], "join");
|
||
await q(
|
||
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
|
||
[abandoned],
|
||
);
|
||
await act(abandoned, users[0]);
|
||
await q(
|
||
"update public.profiles set account_status='banned' where id=any($1::uuid[])",
|
||
[users.slice(0, 2)],
|
||
);
|
||
assert.equal((await act(abandoned, null)).status, "cancelled");
|
||
await assert.rejects(create(), /Active account/);
|
||
await q(
|
||
"update public.profiles set account_status='active' where id=any($1::uuid[])",
|
||
[users.slice(0, 2)],
|
||
);
|
||
// Account removal must preserve event history instead of blocking Auth deletion.
|
||
await q("delete from auth.users where id=$1", [users[0]]);
|
||
assert.equal((await act(tid, null)).ownerId, null);
|
||
console.log(
|
||
"Tournament SQL passed: 32-player cap, admission/auth, private rooms, 2×5 mini-rounds, shared/per-mini clocks, server scores, schedules/outbox leases, immutable results, seven-day champion and account deletion.",
|
||
);
|
||
} catch (error) {
|
||
console.error(
|
||
error.message,
|
||
error.where ?? "",
|
||
error.stack?.split("\n").slice(0, 3).join("\n"),
|
||
);
|
||
process.exitCode = 1;
|
||
} finally {
|
||
await db.close();
|
||
}
|