refactor(user): streamline document and TTS segment transfer logic
Simplify user document transfer by consolidating storage and metadata handling. Remove inline deletion of shared blobs; only metadata is moved, and TTS segment transfer is controlled via options. Add `skipStorage` option for test scenarios to bypass storage operations. Update tests to reflect new transfer behavior.
This commit is contained in:
parent
16cada6129
commit
664c719717
2 changed files with 65 additions and 73 deletions
|
|
@ -21,9 +21,8 @@ import {
|
|||
import { isS3Configured } from '../storage/s3';
|
||||
import { logDegraded } from '../errors/logging';
|
||||
import { hashForLog, serverLogger } from '../logger';
|
||||
import { deleteOwnedDocument } from '../documents/delete-owned';
|
||||
import { getS3Config } from '../storage/s3';
|
||||
import { copyTtsSegmentPrefix } from '../tts/segments-blobstore';
|
||||
import { copyTtsSegmentPrefix, deleteTtsSegmentPrefix } from '../tts/segments-blobstore';
|
||||
import { buildTtsSegmentDocumentPrefix } from '../tts/segments';
|
||||
|
||||
type AudiobookRow = {
|
||||
|
|
@ -185,57 +184,40 @@ export async function transferUserDocuments(
|
|||
fromUserId: string,
|
||||
toUserId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options?: { db?: any; namespace?: string | null; transferTts?: boolean },
|
||||
options?: { db?: any; namespace?: string | null; transferTts?: boolean; skipStorage?: boolean },
|
||||
): Promise<number> {
|
||||
if (!fromUserId || !toUserId) return 0;
|
||||
if (fromUserId === toUserId) return 0;
|
||||
|
||||
const database = options?.db ?? db;
|
||||
const storageEnabled = !options?.db && isS3Configured();
|
||||
// Object storage is always present in a real deployment; `skipStorage` lets
|
||||
// tests transfer metadata only. The shared, content-addressed document blob is
|
||||
// never touched here — it stays as long as any owner (the new user) remains.
|
||||
const copyStorage = !options?.skipStorage && isS3Configured();
|
||||
|
||||
const rows = await database.select().from(documents).where(eq(documents.userId, fromUserId));
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
if (storageEnabled || options?.transferTts) {
|
||||
for (const row of rows) {
|
||||
await database
|
||||
.insert(documents)
|
||||
.values({ ...row, userId: toUserId })
|
||||
.onConflictDoNothing();
|
||||
if (options?.transferTts) {
|
||||
await transferDocumentTtsSegments({
|
||||
documentId: row.id,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
namespace: options.namespace ?? null,
|
||||
database,
|
||||
copyStorage: storageEnabled,
|
||||
deleteSourceMetadata: !storageEnabled,
|
||||
});
|
||||
}
|
||||
if (storageEnabled) {
|
||||
await deleteOwnedDocument({
|
||||
userId: fromUserId,
|
||||
documentId: row.id,
|
||||
namespace: options?.namespace ?? null,
|
||||
});
|
||||
} else {
|
||||
await database.delete(documents).where(and(
|
||||
eq(documents.userId, fromUserId),
|
||||
eq(documents.id, row.id),
|
||||
));
|
||||
}
|
||||
for (const row of rows) {
|
||||
await database
|
||||
.insert(documents)
|
||||
.values({ ...row, userId: toUserId })
|
||||
.onConflictDoNothing();
|
||||
if (options?.transferTts) {
|
||||
await transferDocumentTtsSegments({
|
||||
documentId: row.id,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
namespace: options.namespace ?? null,
|
||||
database,
|
||||
copyStorage,
|
||||
});
|
||||
}
|
||||
return rows.length;
|
||||
await database.delete(documents).where(and(
|
||||
eq(documents.userId, fromUserId),
|
||||
eq(documents.id, row.id),
|
||||
));
|
||||
}
|
||||
|
||||
await database
|
||||
.insert(documents)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.values(rows.map((row: any) => ({ ...row, userId: toUserId })))
|
||||
.onConflictDoNothing();
|
||||
|
||||
await database.delete(documents).where(eq(documents.userId, fromUserId));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
|
|
@ -247,27 +229,29 @@ async function transferDocumentTtsSegments(input: {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
database: any;
|
||||
copyStorage: boolean;
|
||||
deleteSourceMetadata: boolean;
|
||||
}): Promise<void> {
|
||||
// Built only when storage is in play — getS3Config() throws if storage is
|
||||
// unconfigured, which is exactly the metadata-only path tests exercise.
|
||||
const sourceTtsPrefixes = () => (['v1', 'v2'] as const).map((storageVersion) => ({
|
||||
from: buildTtsSegmentDocumentPrefix({
|
||||
storagePrefix: getS3Config().prefix,
|
||||
namespace: input.namespace,
|
||||
userId: input.fromUserId,
|
||||
documentId: input.documentId,
|
||||
storageVersion,
|
||||
}),
|
||||
to: buildTtsSegmentDocumentPrefix({
|
||||
storagePrefix: getS3Config().prefix,
|
||||
namespace: input.namespace,
|
||||
userId: input.toUserId,
|
||||
documentId: input.documentId,
|
||||
storageVersion,
|
||||
}),
|
||||
}));
|
||||
|
||||
if (input.copyStorage) {
|
||||
const storagePrefix = getS3Config().prefix;
|
||||
for (const storageVersion of ['v1', 'v2'] as const) {
|
||||
await copyTtsSegmentPrefix(
|
||||
buildTtsSegmentDocumentPrefix({
|
||||
storagePrefix,
|
||||
namespace: input.namespace,
|
||||
userId: input.fromUserId,
|
||||
documentId: input.documentId,
|
||||
storageVersion,
|
||||
}),
|
||||
buildTtsSegmentDocumentPrefix({
|
||||
storagePrefix,
|
||||
namespace: input.namespace,
|
||||
userId: input.toUserId,
|
||||
documentId: input.documentId,
|
||||
storageVersion,
|
||||
}),
|
||||
);
|
||||
for (const { from, to } of sourceTtsPrefixes()) {
|
||||
await copyTtsSegmentPrefix(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -337,20 +321,27 @@ async function transferDocumentTtsSegments(input: {
|
|||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (input.deleteSourceMetadata) {
|
||||
if (variants.length > 0) {
|
||||
await input.database.delete(ttsSegmentVariants).where(and(
|
||||
eq(ttsSegmentVariants.userId, input.fromUserId),
|
||||
inArray(ttsSegmentVariants.segmentId, variants.map(
|
||||
(variant: typeof ttsSegmentVariants.$inferSelect) => variant.segmentId,
|
||||
)),
|
||||
));
|
||||
}
|
||||
await input.database.delete(ttsSegmentEntries).where(and(
|
||||
eq(ttsSegmentEntries.userId, input.fromUserId),
|
||||
eq(ttsSegmentEntries.documentId, input.documentId),
|
||||
// Always remove the source metadata: the source account (e.g. the persistent
|
||||
// 'unclaimed' user) is not deleted, so nothing would cascade it away.
|
||||
if (variants.length > 0) {
|
||||
await input.database.delete(ttsSegmentVariants).where(and(
|
||||
eq(ttsSegmentVariants.userId, input.fromUserId),
|
||||
inArray(ttsSegmentVariants.segmentId, variants.map(
|
||||
(variant: typeof ttsSegmentVariants.$inferSelect) => variant.segmentId,
|
||||
)),
|
||||
));
|
||||
}
|
||||
await input.database.delete(ttsSegmentEntries).where(and(
|
||||
eq(ttsSegmentEntries.userId, input.fromUserId),
|
||||
eq(ttsSegmentEntries.documentId, input.documentId),
|
||||
));
|
||||
|
||||
// Remove the now-copied source audio objects too (only when storage is in play).
|
||||
if (input.copyStorage) {
|
||||
for (const { from } of sourceTtsPrefixes()) {
|
||||
await deleteTtsSegmentPrefix(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ describe('transferUserDocuments', () => {
|
|||
await transferUserDocuments('anon', 'user', {
|
||||
db: transferDb,
|
||||
transferTts: true,
|
||||
skipStorage: true,
|
||||
});
|
||||
|
||||
expect(await transferDb.select().from(ttsSegmentEntries)).toEqual([
|
||||
|
|
|
|||
Loading…
Reference in a new issue