Files
twitter-lite/src/features/decks/repository.server.ts
T

173 lines
5.1 KiB
TypeScript

import { and, asc, eq } from "drizzle-orm";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections, deckColumns, decks } from "../storage/schema";
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
type SavedDeck,
type SavedDeckDefinition,
savedDeckDefinitionSchema,
savedDeckSchema,
} from "./persistence-model";
export class DeckPersistenceError extends Error {
constructor(
readonly code: DeckPersistenceFailure["error"]["code"],
message: string,
) {
super(message);
}
}
const conflict = () =>
new DeckPersistenceError(
"conflict",
"This deck changed on another device. Reload the latest deck.",
);
const missing = () =>
new DeckPersistenceError("not-found", "This deck was deleted or could not be found.");
export function loadDeck(id: string, database = getDatabase()): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get();
if (!record) return null;
const columns = database
.select({
id: deckColumns.id,
title: deckColumns.title,
connectionId: deckColumns.connectionId,
source: deckColumns.source,
})
.from(deckColumns)
.where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position))
.all();
return savedDeckSchema.parse({ ...record, columns });
}
export function listDecks(database = getDatabase()): SavedDeck[] {
return database.transaction(() =>
database
.select({ id: decks.id })
.from(decks)
.orderBy(asc(decks.createdAt), asc(decks.id))
.all()
.map(({ id }) => {
const deck = loadDeck(id, database);
if (!deck) throw missing();
return deck;
}),
);
}
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
for (const column of deck.columns) {
const connection = database
.select({ platform: connections.platform, status: connections.status })
.from(connections)
.where(eq(connections.id, column.connectionId))
.get();
if (
!connection ||
connection.platform !== column.source.platform ||
connection.status !== "connected"
) {
throw new DeckPersistenceError(
"connection-unavailable",
"The account for this column is unavailable. Check your connections.",
);
}
}
}
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run();
if (deck.columns.length)
database
.insert(deckColumns)
.values(
deck.columns.map((column, position) => ({
...column,
deckId: deck.id,
position,
})),
)
.run();
}
export function createDeck(input: unknown, database = getDatabase()): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input);
return database.transaction(
() => {
const existing = loadDeck(deck.id, database);
if (existing) {
const original = savedDeckDefinitionSchema.parse({
id: existing.id,
title: existing.title,
columns: existing.columns,
});
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict();
return existing;
}
validateConnections(deck, database);
const now = Date.now();
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now };
database
.insert(decks)
.values({
id: deck.id,
title: deck.title,
revision: 1,
createdAt: now,
updatedAt: now,
})
.run();
writeColumns(deck, database);
return result;
},
{ behavior: "immediate" },
);
}
export function replaceDeck(input: unknown, database = getDatabase()): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input);
return database.transaction(
() => {
const existing = loadDeck(deck.id, database);
if (!existing) throw missing();
if (existing.revision !== expectedRevision) throw conflict();
validateConnections(deck, database);
const updatedAt = Date.now();
const result = database
.update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run();
if (result.changes !== 1) throw conflict();
writeColumns(deck, database);
return {
...deck,
revision: expectedRevision + 1,
createdAt: existing.createdAt,
updatedAt,
};
},
{ behavior: "immediate" },
);
}
export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input);
return database.transaction(
() => {
if (!loadDeck(id, database)) throw missing();
const result = database
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run();
if (result.changes !== 1) throw conflict();
return id;
},
{ behavior: "immediate" },
);
}