feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
+184
View File
@@ -0,0 +1,184 @@
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',
'別の端末で変更されました。最新のデッキを読み直してください。',
)
const missing = () =>
new DeckPersistenceError(
'not-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',
'カラムの接続アカウントが利用できません。接続を確認してください。',
)
}
}
}
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' },
)
}