173 lines
5.1 KiB
TypeScript
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" },
|
|
);
|
|
}
|