mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
Fix worker getting stuck during sync: add timeouts, stuck detection, and safety limits
- Add invokeWithTimeout wrapper for TDLib API calls (2min timeout per call) - Add stuck detection to getChannelMessages: break if from_message_id doesn't advance - Add stuck detection to getTopicMessages: same protection for topic scanning - Add stuck detection to getForumTopicList: break if pagination offsets don't advance - Add max page limit (5000) to all scanning loops to prevent infinite pagination - Add mutex wait timeout (30min) to prevent indefinite blocking when holder hangs - Add cycle timeout (4h default, configurable via WORKER_CYCLE_TIMEOUT_MINUTES) - Fix end-of-page detection to use actual limit value instead of hardcoded 100 Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
This commit is contained in:
7
worker/dist/db/client.d.ts
vendored
Normal file
7
worker/dist/db/client.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
declare const pool: import("pg").Pool;
|
||||
export declare const db: PrismaClient<{
|
||||
adapter: PrismaPg;
|
||||
}, never, import("@prisma/client/runtime/client").DefaultArgs>;
|
||||
export { pool };
|
||||
12
worker/dist/db/client.js
vendored
Normal file
12
worker/dist/db/client.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import pg from "pg";
|
||||
import { config } from "../util/config.js";
|
||||
const pool = new pg.Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 5,
|
||||
});
|
||||
const adapter = new PrismaPg(pool);
|
||||
export const db = new PrismaClient({ adapter });
|
||||
export { pool };
|
||||
//# sourceMappingURL=client.js.map
|
||||
1
worker/dist/db/client.js.map
vendored
Normal file
1
worker/dist/db/client.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/db/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE3C,MAAM,IAAI,GAAG,IAAI,EAAE,CAAC,IAAI,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC,WAAW;IACpC,GAAG,EAAE,CAAC;CACP,CAAC,CAAC;AAEH,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;AACnC,MAAM,CAAC,MAAM,EAAE,GAAG,IAAI,YAAY,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;AAEhD,OAAO,EAAE,IAAI,EAAE,CAAC"}
|
||||
9
worker/dist/db/locks.d.ts
vendored
Normal file
9
worker/dist/db/locks.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Try to acquire a PostgreSQL advisory lock for an account.
|
||||
* Returns true if acquired, false if already held by another session.
|
||||
*/
|
||||
export declare function tryAcquireLock(accountId: string): Promise<boolean>;
|
||||
/**
|
||||
* Release the advisory lock for an account.
|
||||
*/
|
||||
export declare function releaseLock(accountId: string): Promise<void>;
|
||||
53
worker/dist/db/locks.js
vendored
Normal file
53
worker/dist/db/locks.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import { pool } from "./client.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
const log = childLogger("locks");
|
||||
/**
|
||||
* Derive a stable 32-bit integer lock ID from an account ID string.
|
||||
* PostgreSQL advisory locks use bigint, but we use 32-bit for safety.
|
||||
*/
|
||||
function hashToLockId(accountId) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < accountId.length; i++) {
|
||||
const char = accountId.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash |= 0; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
/**
|
||||
* Try to acquire a PostgreSQL advisory lock for an account.
|
||||
* Returns true if acquired, false if already held by another session.
|
||||
*/
|
||||
export async function tryAcquireLock(accountId) {
|
||||
const lockId = hashToLockId(accountId);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const result = await client.query("SELECT pg_try_advisory_lock($1)", [lockId]);
|
||||
const acquired = result.rows[0]?.pg_try_advisory_lock ?? false;
|
||||
if (acquired) {
|
||||
log.debug({ accountId, lockId }, "Advisory lock acquired");
|
||||
}
|
||||
else {
|
||||
log.debug({ accountId, lockId }, "Advisory lock already held");
|
||||
}
|
||||
return acquired;
|
||||
}
|
||||
finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Release the advisory lock for an account.
|
||||
*/
|
||||
export async function releaseLock(accountId) {
|
||||
const lockId = hashToLockId(accountId);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [lockId]);
|
||||
log.debug({ accountId, lockId }, "Advisory lock released");
|
||||
}
|
||||
finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=locks.js.map
|
||||
1
worker/dist/db/locks.js.map
vendored
Normal file
1
worker/dist/db/locks.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"locks.js","sourceRoot":"","sources":["../../src/db/locks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,MAAM,GAAG,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;AAEjC;;;GAGG;AACH,SAAS,YAAY,CAAC,SAAiB;IACrC,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC1C,MAAM,IAAI,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QACrC,IAAI,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;QACjC,IAAI,IAAI,CAAC,CAAC,CAAC,4BAA4B;IACzC,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,SAAiB;IACpD,MAAM,MAAM,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAC/B,iCAAiC,EACjC,CAAC,MAAM,CAAC,CACT,CAAC;QACF,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,oBAAoB,IAAI,KAAK,CAAC;QAC/D,IAAI,QAAQ,EAAE,CAAC;YACb,GAAG,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,wBAAwB,CAAC,CAAC;QAC7D,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,4BAA4B,CAAC,CAAC;QACjE,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,SAAiB;IACjD,MAAM,MAAM,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,+BAA+B,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;QAC9D,GAAG,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,wBAAwB,CAAC,CAAC;IAC7D,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC"}
|
||||
356
worker/dist/db/queries.d.ts
vendored
Normal file
356
worker/dist/db/queries.d.ts
vendored
Normal file
@@ -0,0 +1,356 @@
|
||||
import type { ArchiveType, FetchStatus } from "@prisma/client";
|
||||
export declare function getActiveAccounts(): Promise<{
|
||||
id: string;
|
||||
phone: string;
|
||||
displayName: string | null;
|
||||
isActive: boolean;
|
||||
authState: import("@prisma/client").$Enums.AuthState;
|
||||
authCode: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}[]>;
|
||||
export declare function getPendingAccounts(): Promise<{
|
||||
id: string;
|
||||
phone: string;
|
||||
displayName: string | null;
|
||||
isActive: boolean;
|
||||
authState: import("@prisma/client").$Enums.AuthState;
|
||||
authCode: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}[]>;
|
||||
export declare function hasAnyChannels(): Promise<boolean>;
|
||||
export declare function getSourceChannelMappings(accountId: string): Promise<({
|
||||
channel: {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
telegramId: bigint;
|
||||
title: string;
|
||||
type: import("@prisma/client").$Enums.ChannelType;
|
||||
isForum: boolean;
|
||||
};
|
||||
} & {
|
||||
accountId: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
channelId: string;
|
||||
role: import("@prisma/client").$Enums.ChannelRole;
|
||||
lastProcessedMessageId: bigint | null;
|
||||
})[]>;
|
||||
export declare function getGlobalDestinationChannel(): Promise<{
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
telegramId: bigint;
|
||||
title: string;
|
||||
type: import("@prisma/client").$Enums.ChannelType;
|
||||
isForum: boolean;
|
||||
} | null>;
|
||||
export declare function getGlobalSetting(key: string): Promise<string | null>;
|
||||
export declare function setGlobalSetting(key: string, value: string): Promise<{
|
||||
updatedAt: Date;
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
export declare function packageExistsByHash(contentHash: string): Promise<boolean>;
|
||||
/**
|
||||
* Check if a package already exists for a given source message ID
|
||||
* AND was successfully uploaded to the destination (destMessageId is set).
|
||||
* Used as an early skip before downloading.
|
||||
*/
|
||||
export declare function packageExistsBySourceMessage(sourceChannelId: string, sourceMessageId: bigint): Promise<boolean>;
|
||||
/**
|
||||
* Delete orphaned Package rows that have the same content hash but never
|
||||
* completed the upload (destMessageId is null). Called before creating a
|
||||
* new complete record to avoid unique constraint violations.
|
||||
*/
|
||||
export declare function deleteOrphanedPackageByHash(contentHash: string): Promise<void>;
|
||||
export interface CreatePackageInput {
|
||||
contentHash: string;
|
||||
fileName: string;
|
||||
fileSize: bigint;
|
||||
archiveType: ArchiveType;
|
||||
sourceChannelId: string;
|
||||
sourceMessageId: bigint;
|
||||
sourceTopicId?: bigint | null;
|
||||
destChannelId?: string;
|
||||
destMessageId?: bigint;
|
||||
isMultipart: boolean;
|
||||
partCount: number;
|
||||
ingestionRunId: string;
|
||||
creator?: string | null;
|
||||
previewData?: Buffer | null;
|
||||
previewMsgId?: bigint | null;
|
||||
files: {
|
||||
path: string;
|
||||
fileName: string;
|
||||
extension: string | null;
|
||||
compressedSize: bigint;
|
||||
uncompressedSize: bigint;
|
||||
crc32: string | null;
|
||||
}[];
|
||||
}
|
||||
export declare function createPackageWithFiles(input: CreatePackageInput): Promise<{
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
contentHash: string;
|
||||
fileName: string;
|
||||
fileSize: bigint;
|
||||
archiveType: import("@prisma/client").$Enums.ArchiveType;
|
||||
creator: string | null;
|
||||
sourceChannelId: string;
|
||||
sourceMessageId: bigint;
|
||||
sourceTopicId: bigint | null;
|
||||
destChannelId: string | null;
|
||||
destMessageId: bigint | null;
|
||||
isMultipart: boolean;
|
||||
partCount: number;
|
||||
fileCount: number;
|
||||
previewData: import("@prisma/client/runtime/client").Bytes | null;
|
||||
previewMsgId: bigint | null;
|
||||
indexedAt: Date;
|
||||
ingestionRunId: string | null;
|
||||
}>;
|
||||
export declare function createIngestionRun(accountId: string): Promise<{
|
||||
accountId: string;
|
||||
id: string;
|
||||
status: import("@prisma/client").$Enums.IngestionStatus;
|
||||
startedAt: Date;
|
||||
finishedAt: Date | null;
|
||||
messagesScanned: number;
|
||||
zipsFound: number;
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
errorMessage: string | null;
|
||||
currentActivity: string | null;
|
||||
currentStep: string | null;
|
||||
currentChannel: string | null;
|
||||
currentFile: string | null;
|
||||
currentFileNum: number | null;
|
||||
totalFiles: number | null;
|
||||
downloadedBytes: bigint | null;
|
||||
totalBytes: bigint | null;
|
||||
downloadPercent: number | null;
|
||||
lastActivityAt: Date | null;
|
||||
}>;
|
||||
export interface ActivityUpdate {
|
||||
currentActivity: string;
|
||||
currentStep: string;
|
||||
currentChannel?: string | null;
|
||||
currentFile?: string | null;
|
||||
currentFileNum?: number | null;
|
||||
totalFiles?: number | null;
|
||||
downloadedBytes?: bigint | null;
|
||||
totalBytes?: bigint | null;
|
||||
downloadPercent?: number | null;
|
||||
messagesScanned?: number;
|
||||
zipsFound?: number;
|
||||
zipsDuplicate?: number;
|
||||
zipsIngested?: number;
|
||||
}
|
||||
export declare function updateRunActivity(runId: string, activity: ActivityUpdate): Promise<{
|
||||
accountId: string;
|
||||
id: string;
|
||||
status: import("@prisma/client").$Enums.IngestionStatus;
|
||||
startedAt: Date;
|
||||
finishedAt: Date | null;
|
||||
messagesScanned: number;
|
||||
zipsFound: number;
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
errorMessage: string | null;
|
||||
currentActivity: string | null;
|
||||
currentStep: string | null;
|
||||
currentChannel: string | null;
|
||||
currentFile: string | null;
|
||||
currentFileNum: number | null;
|
||||
totalFiles: number | null;
|
||||
downloadedBytes: bigint | null;
|
||||
totalBytes: bigint | null;
|
||||
downloadPercent: number | null;
|
||||
lastActivityAt: Date | null;
|
||||
}>;
|
||||
export declare function completeIngestionRun(runId: string, counters: {
|
||||
messagesScanned: number;
|
||||
zipsFound: number;
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
}): Promise<{
|
||||
accountId: string;
|
||||
id: string;
|
||||
status: import("@prisma/client").$Enums.IngestionStatus;
|
||||
startedAt: Date;
|
||||
finishedAt: Date | null;
|
||||
messagesScanned: number;
|
||||
zipsFound: number;
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
errorMessage: string | null;
|
||||
currentActivity: string | null;
|
||||
currentStep: string | null;
|
||||
currentChannel: string | null;
|
||||
currentFile: string | null;
|
||||
currentFileNum: number | null;
|
||||
totalFiles: number | null;
|
||||
downloadedBytes: bigint | null;
|
||||
totalBytes: bigint | null;
|
||||
downloadPercent: number | null;
|
||||
lastActivityAt: Date | null;
|
||||
}>;
|
||||
export declare function failIngestionRun(runId: string, errorMessage: string): Promise<{
|
||||
accountId: string;
|
||||
id: string;
|
||||
status: import("@prisma/client").$Enums.IngestionStatus;
|
||||
startedAt: Date;
|
||||
finishedAt: Date | null;
|
||||
messagesScanned: number;
|
||||
zipsFound: number;
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
errorMessage: string | null;
|
||||
currentActivity: string | null;
|
||||
currentStep: string | null;
|
||||
currentChannel: string | null;
|
||||
currentFile: string | null;
|
||||
currentFileNum: number | null;
|
||||
totalFiles: number | null;
|
||||
downloadedBytes: bigint | null;
|
||||
totalBytes: bigint | null;
|
||||
downloadPercent: number | null;
|
||||
lastActivityAt: Date | null;
|
||||
}>;
|
||||
export declare function updateLastProcessedMessage(mappingId: string, messageId: bigint): Promise<{
|
||||
accountId: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
channelId: string;
|
||||
role: import("@prisma/client").$Enums.ChannelRole;
|
||||
lastProcessedMessageId: bigint | null;
|
||||
}>;
|
||||
export declare function markStaleRunsAsFailed(): Promise<import("@prisma/client").Prisma.BatchPayload>;
|
||||
export declare function updateAccountAuthState(accountId: string, authState: "PENDING" | "AWAITING_CODE" | "AWAITING_PASSWORD" | "AUTHENTICATED" | "EXPIRED", authCode?: string | null): Promise<{
|
||||
id: string;
|
||||
phone: string;
|
||||
displayName: string | null;
|
||||
isActive: boolean;
|
||||
authState: import("@prisma/client").$Enums.AuthState;
|
||||
authCode: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
export declare function getAccountAuthCode(accountId: string): Promise<{
|
||||
authState: import("@prisma/client").$Enums.AuthState;
|
||||
authCode: string | null;
|
||||
} | null>;
|
||||
export interface UpsertChannelInput {
|
||||
telegramId: bigint;
|
||||
title: string;
|
||||
type: "SOURCE" | "DESTINATION";
|
||||
isForum: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
/**
|
||||
* Upsert a channel by telegramId. Returns the channel record.
|
||||
* If it already exists, update title and forum status.
|
||||
* New channels default to disabled (isActive: false) so the admin must
|
||||
* explicitly enable them before the worker processes them.
|
||||
* Pass isActive: true for DESTINATION channels that must be active immediately.
|
||||
*/
|
||||
export declare function upsertChannel(input: UpsertChannelInput): Promise<{
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
telegramId: bigint;
|
||||
title: string;
|
||||
type: import("@prisma/client").$Enums.ChannelType;
|
||||
isForum: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Link an account to a channel if not already linked.
|
||||
* Uses a try/catch on unique constraint to make it idempotent.
|
||||
*/
|
||||
export declare function ensureAccountChannelLink(accountId: string, channelId: string, role: "READER" | "WRITER"): Promise<{
|
||||
accountId: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
channelId: string;
|
||||
role: import("@prisma/client").$Enums.ChannelRole;
|
||||
lastProcessedMessageId: bigint | null;
|
||||
} | null>;
|
||||
export declare function setChannelForum(channelId: string, isForum: boolean): Promise<{
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
telegramId: bigint;
|
||||
title: string;
|
||||
type: import("@prisma/client").$Enums.ChannelType;
|
||||
isForum: boolean;
|
||||
}>;
|
||||
export declare function getTopicProgress(mappingId: string): Promise<{
|
||||
id: string;
|
||||
lastProcessedMessageId: bigint | null;
|
||||
accountChannelMapId: string;
|
||||
topicId: bigint;
|
||||
topicName: string | null;
|
||||
}[]>;
|
||||
export declare function upsertTopicProgress(mappingId: string, topicId: bigint, topicName: string | null, lastProcessedMessageId: bigint): Promise<{
|
||||
id: string;
|
||||
lastProcessedMessageId: bigint | null;
|
||||
accountChannelMapId: string;
|
||||
topicId: bigint;
|
||||
topicName: string | null;
|
||||
}>;
|
||||
export declare function getChannelFetchRequest(requestId: string): Promise<({
|
||||
account: {
|
||||
id: string;
|
||||
phone: string;
|
||||
displayName: string | null;
|
||||
isActive: boolean;
|
||||
authState: import("@prisma/client").$Enums.AuthState;
|
||||
authCode: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
} & {
|
||||
error: string | null;
|
||||
accountId: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
status: import("@prisma/client").$Enums.FetchStatus;
|
||||
resultJson: string | null;
|
||||
}) | null>;
|
||||
export declare function updateFetchRequestStatus(requestId: string, status: FetchStatus, extra?: {
|
||||
resultJson?: string;
|
||||
error?: string;
|
||||
}): Promise<{
|
||||
error: string | null;
|
||||
accountId: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
status: import("@prisma/client").$Enums.FetchStatus;
|
||||
resultJson: string | null;
|
||||
}>;
|
||||
export declare function getAccountLinkedChannelIds(accountId: string): Promise<Set<string>>;
|
||||
export declare function getExistingChannelsByTelegramId(): Promise<Map<string, string>>;
|
||||
export declare function getAccountById(accountId: string): Promise<{
|
||||
id: string;
|
||||
phone: string;
|
||||
displayName: string | null;
|
||||
isActive: boolean;
|
||||
authState: import("@prisma/client").$Enums.AuthState;
|
||||
authCode: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
} | null>;
|
||||
319
worker/dist/db/queries.js
vendored
Normal file
319
worker/dist/db/queries.js
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
import { db } from "./client.js";
|
||||
export async function getActiveAccounts() {
|
||||
return db.telegramAccount.findMany({
|
||||
where: { isActive: true, authState: "AUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
export async function getPendingAccounts() {
|
||||
return db.telegramAccount.findMany({
|
||||
where: { isActive: true, authState: "PENDING" },
|
||||
});
|
||||
}
|
||||
export async function hasAnyChannels() {
|
||||
const count = await db.telegramChannel.count();
|
||||
return count > 0;
|
||||
}
|
||||
export async function getSourceChannelMappings(accountId) {
|
||||
return db.accountChannelMap.findMany({
|
||||
where: {
|
||||
accountId,
|
||||
role: "READER",
|
||||
channel: { type: "SOURCE", isActive: true },
|
||||
},
|
||||
include: { channel: true },
|
||||
});
|
||||
}
|
||||
// ── Global destination channel ──
|
||||
export async function getGlobalDestinationChannel() {
|
||||
const setting = await db.globalSetting.findUnique({
|
||||
where: { key: "destination_channel_id" },
|
||||
});
|
||||
if (!setting)
|
||||
return null;
|
||||
return db.telegramChannel.findFirst({
|
||||
where: { id: setting.value, type: "DESTINATION", isActive: true },
|
||||
});
|
||||
}
|
||||
export async function getGlobalSetting(key) {
|
||||
const setting = await db.globalSetting.findUnique({ where: { key } });
|
||||
return setting?.value ?? null;
|
||||
}
|
||||
export async function setGlobalSetting(key, value) {
|
||||
return db.globalSetting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
export async function packageExistsByHash(contentHash) {
|
||||
const pkg = await db.package.findFirst({
|
||||
where: { contentHash, destMessageId: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
return pkg !== null;
|
||||
}
|
||||
/**
|
||||
* Check if a package already exists for a given source message ID
|
||||
* AND was successfully uploaded to the destination (destMessageId is set).
|
||||
* Used as an early skip before downloading.
|
||||
*/
|
||||
export async function packageExistsBySourceMessage(sourceChannelId, sourceMessageId) {
|
||||
const pkg = await db.package.findFirst({
|
||||
where: { sourceChannelId, sourceMessageId, destMessageId: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
return pkg !== null;
|
||||
}
|
||||
/**
|
||||
* Delete orphaned Package rows that have the same content hash but never
|
||||
* completed the upload (destMessageId is null). Called before creating a
|
||||
* new complete record to avoid unique constraint violations.
|
||||
*/
|
||||
export async function deleteOrphanedPackageByHash(contentHash) {
|
||||
await db.package.deleteMany({
|
||||
where: { contentHash, destMessageId: null },
|
||||
});
|
||||
}
|
||||
export async function createPackageWithFiles(input) {
|
||||
const pkg = await db.package.create({
|
||||
data: {
|
||||
contentHash: input.contentHash,
|
||||
fileName: input.fileName,
|
||||
fileSize: input.fileSize,
|
||||
archiveType: input.archiveType,
|
||||
sourceChannelId: input.sourceChannelId,
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
sourceTopicId: input.sourceTopicId ?? undefined,
|
||||
destChannelId: input.destChannelId,
|
||||
destMessageId: input.destMessageId,
|
||||
isMultipart: input.isMultipart,
|
||||
partCount: input.partCount,
|
||||
fileCount: input.files.length,
|
||||
ingestionRunId: input.ingestionRunId,
|
||||
creator: input.creator ?? undefined,
|
||||
previewData: input.previewData ? new Uint8Array(input.previewData) : undefined,
|
||||
previewMsgId: input.previewMsgId ?? undefined,
|
||||
files: {
|
||||
create: input.files,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Notify the bot service about the new package (for subscription alerts)
|
||||
try {
|
||||
await db.$queryRawUnsafe(`SELECT pg_notify('new_package', $1)`, JSON.stringify({
|
||||
packageId: pkg.id,
|
||||
fileName: input.fileName,
|
||||
creator: input.creator ?? null,
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
// Best-effort — don't fail the ingestion if notification fails
|
||||
}
|
||||
return pkg;
|
||||
}
|
||||
export async function createIngestionRun(accountId) {
|
||||
return db.ingestionRun.create({
|
||||
data: {
|
||||
accountId,
|
||||
status: "RUNNING",
|
||||
currentActivity: "Starting ingestion run",
|
||||
currentStep: "initializing",
|
||||
lastActivityAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function updateRunActivity(runId, activity) {
|
||||
return db.ingestionRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
currentActivity: activity.currentActivity,
|
||||
currentStep: activity.currentStep,
|
||||
currentChannel: activity.currentChannel ?? undefined,
|
||||
currentFile: activity.currentFile ?? undefined,
|
||||
currentFileNum: activity.currentFileNum ?? undefined,
|
||||
totalFiles: activity.totalFiles ?? undefined,
|
||||
downloadedBytes: activity.downloadedBytes ?? undefined,
|
||||
totalBytes: activity.totalBytes ?? undefined,
|
||||
downloadPercent: activity.downloadPercent ?? undefined,
|
||||
lastActivityAt: new Date(),
|
||||
...(activity.messagesScanned !== undefined && { messagesScanned: activity.messagesScanned }),
|
||||
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
|
||||
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
|
||||
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
|
||||
},
|
||||
});
|
||||
}
|
||||
const CLEAR_ACTIVITY = {
|
||||
currentActivity: null,
|
||||
currentStep: null,
|
||||
currentChannel: null,
|
||||
currentFile: null,
|
||||
currentFileNum: null,
|
||||
totalFiles: null,
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
downloadPercent: null,
|
||||
lastActivityAt: new Date(),
|
||||
};
|
||||
export async function completeIngestionRun(runId, counters) {
|
||||
return db.ingestionRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
finishedAt: new Date(),
|
||||
...counters,
|
||||
...CLEAR_ACTIVITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function failIngestionRun(runId, errorMessage) {
|
||||
return db.ingestionRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
finishedAt: new Date(),
|
||||
errorMessage,
|
||||
...CLEAR_ACTIVITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function updateLastProcessedMessage(mappingId, messageId) {
|
||||
return db.accountChannelMap.update({
|
||||
where: { id: mappingId },
|
||||
data: { lastProcessedMessageId: messageId },
|
||||
});
|
||||
}
|
||||
export async function markStaleRunsAsFailed() {
|
||||
return db.ingestionRun.updateMany({
|
||||
where: { status: "RUNNING" },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
finishedAt: new Date(),
|
||||
errorMessage: "Worker restarted — run was still marked as RUNNING",
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function updateAccountAuthState(accountId, authState, authCode) {
|
||||
return db.telegramAccount.update({
|
||||
where: { id: accountId },
|
||||
data: { authState, authCode, lastSeenAt: authState === "AUTHENTICATED" ? new Date() : undefined },
|
||||
});
|
||||
}
|
||||
export async function getAccountAuthCode(accountId) {
|
||||
const account = await db.telegramAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
select: { authCode: true, authState: true },
|
||||
});
|
||||
return account;
|
||||
}
|
||||
/**
|
||||
* Upsert a channel by telegramId. Returns the channel record.
|
||||
* If it already exists, update title and forum status.
|
||||
* New channels default to disabled (isActive: false) so the admin must
|
||||
* explicitly enable them before the worker processes them.
|
||||
* Pass isActive: true for DESTINATION channels that must be active immediately.
|
||||
*/
|
||||
export async function upsertChannel(input) {
|
||||
return db.telegramChannel.upsert({
|
||||
where: { telegramId: input.telegramId },
|
||||
create: {
|
||||
telegramId: input.telegramId,
|
||||
title: input.title,
|
||||
type: input.type,
|
||||
isForum: input.isForum,
|
||||
isActive: input.isActive ?? false,
|
||||
},
|
||||
update: {
|
||||
title: input.title,
|
||||
isForum: input.isForum,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Link an account to a channel if not already linked.
|
||||
* Uses a try/catch on unique constraint to make it idempotent.
|
||||
*/
|
||||
export async function ensureAccountChannelLink(accountId, channelId, role) {
|
||||
try {
|
||||
return await db.accountChannelMap.create({
|
||||
data: { accountId, channelId, role },
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
// Already linked — ignore unique constraint violation
|
||||
if (err instanceof Error && err.message.includes("Unique constraint")) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// ── Forum / Topic progress ──
|
||||
export async function setChannelForum(channelId, isForum) {
|
||||
return db.telegramChannel.update({
|
||||
where: { id: channelId },
|
||||
data: { isForum },
|
||||
});
|
||||
}
|
||||
export async function getTopicProgress(mappingId) {
|
||||
return db.topicProgress.findMany({
|
||||
where: { accountChannelMapId: mappingId },
|
||||
});
|
||||
}
|
||||
export async function upsertTopicProgress(mappingId, topicId, topicName, lastProcessedMessageId) {
|
||||
return db.topicProgress.upsert({
|
||||
where: {
|
||||
accountChannelMapId_topicId: {
|
||||
accountChannelMapId: mappingId,
|
||||
topicId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
accountChannelMapId: mappingId,
|
||||
topicId,
|
||||
topicName,
|
||||
lastProcessedMessageId,
|
||||
},
|
||||
update: {
|
||||
topicName,
|
||||
lastProcessedMessageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
// ── Channel fetch requests (DB-mediated communication with web app) ──
|
||||
export async function getChannelFetchRequest(requestId) {
|
||||
return db.channelFetchRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { account: true },
|
||||
});
|
||||
}
|
||||
export async function updateFetchRequestStatus(requestId, status, extra) {
|
||||
return db.channelFetchRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status,
|
||||
resultJson: extra?.resultJson ?? undefined,
|
||||
error: extra?.error ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function getAccountLinkedChannelIds(accountId) {
|
||||
const links = await db.accountChannelMap.findMany({
|
||||
where: { accountId },
|
||||
select: { channel: { select: { telegramId: true } } },
|
||||
});
|
||||
return new Set(links.map((l) => l.channel.telegramId.toString()));
|
||||
}
|
||||
export async function getExistingChannelsByTelegramId() {
|
||||
const channels = await db.telegramChannel.findMany({
|
||||
select: { id: true, telegramId: true },
|
||||
});
|
||||
const map = new Map();
|
||||
for (const ch of channels) {
|
||||
map.set(ch.telegramId.toString(), ch.id);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
export async function getAccountById(accountId) {
|
||||
return db.telegramAccount.findUnique({ where: { id: accountId } });
|
||||
}
|
||||
//# sourceMappingURL=queries.js.map
|
||||
1
worker/dist/db/queries.js.map
vendored
Normal file
1
worker/dist/db/queries.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user