merge: task 2 schema

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:52:10 +01:00
co-authored by Claude Opus 5.5
6 changed files with 928 additions and 23 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ Use impeccable for user-facing UI: `craft` for new UI, `audit`/`polish` on exist
- Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core
internals, and they return `SourceRecord`s instead of writing.
- `packages/*` and `plugins/*` never import from `apps/*`.
- IDs are ULIDs generated in app code. Ratings are integers 0–100. Dates carry a precision
- IDs are UUIDv7 (`newId()`) generated in app code. Ratings are integers 0–100. Dates carry a precision
(`day | month | year | unknown`).
- Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness.
- Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1.
+425
View File
@@ -0,0 +1,425 @@
import type { Kysely } from "kysely";
import type { Migration } from "kysely/migration";
/**
* Column kinds: `t` text, `i` integer, `r` real, `fk:<table>` text reference. Not null by default;
* a trailing `?` makes it nullable. Only portable types, so the same code runs on SQLite and Postgres.
*/
type Kind = string;
type Spec = Array<[name: string, kind: Kind]>;
async function create(
db: Kysely<any>,
name: string,
cols: Spec,
opts: { unique?: string[][]; indexes?: string[][] } = {},
): Promise<void> {
let b = db.schema.createTable(name).addColumn("id", "text", (c) => c.primaryKey());
const fkCols: string[] = [];
for (const [col, kind] of cols) {
const nullable = kind.endsWith("?");
const base = nullable ? kind.slice(0, -1) : kind;
const isFk = base.startsWith("fk:");
const type = base === "i" ? "integer" : base === "r" ? "real" : "text";
if (isFk) fkCols.push(col);
b = b.addColumn(col, type, (c) => {
let x = c;
if (isFk) x = x.references(`${base.slice(3)}.id`);
return nullable ? x : x.notNull();
});
}
for (const u of opts.unique ?? []) b = b.addUniqueConstraint(`${name}_${u.join("_")}_uq`, u as Array<"id">);
await b.execute();
const indexes = [...fkCols.map((c) => [c]), ...(opts.indexes ?? [])];
for (const cols of indexes) {
await db.schema
.createIndex(`${name}_${cols.join("_")}_idx`)
.on(name)
.columns(cols as Array<"id">)
.execute();
}
}
export const initial: Migration = {
async up(db: Kysely<any>) {
await create(
db,
"users",
[
["username", "t"],
["display_name", "t"],
["password_hash", "t"],
["role", "t"],
["preferences", "t"],
["created_at", "t"],
["updated_at", "t"],
],
{ unique: [["username"]] },
);
await create(
db,
"sessions",
[
["user_id", "fk:users"],
["token_hash", "t"],
["expires_at", "t"],
["created_at", "t"],
],
{ unique: [["token_hash"]] },
);
await create(
db,
"invites",
[
["code_hash", "t"],
["created_by", "fk:users"],
["used_by", "fk:users?"],
["expires_at", "t"],
["created_at", "t"],
],
{ unique: [["code_hash"]] },
);
await create(
db,
"app_tokens",
[
["user_id", "fk:users"],
["name", "t"],
["token_hash", "t"],
["scopes", "t"],
["last_used_at", "t?"],
["revoked_at", "t?"],
["created_at", "t"],
],
{ unique: [["token_hash"]] },
);
// works.parent_id is a self reference; works.cover_attachment_id and merged_into_id stay
// plain columns (attachments is created later; merges may point at any work).
await create(
db,
"works",
[
["media_type", "t"],
["title", "t"],
["original_title", "t"],
["year", "i?"],
["release_date", "t?"],
["creators", "t"],
["description", "t"],
["cover_url", "t"],
["cover_attachment_id", "t?"],
["metadata", "t"],
["parent_id", "fk:works?"],
["position", "t?"],
["owner_user_id", "fk:users?"],
["merged_into_id", "t?"],
["created_at", "t"],
["updated_at", "t"],
],
{ indexes: [["merged_into_id"], ["media_type"]] },
);
await create(
db,
"external_ids",
[
["work_id", "fk:works"],
["namespace", "t"],
["value", "t"],
],
{ unique: [["namespace", "value"]] },
);
await create(
db,
"work_aliases",
[
["user_id", "fk:users"],
["from_work_id", "fk:works"],
["to_work_id", "fk:works"],
],
{ unique: [["user_id", "from_work_id"]] },
);
await create(db, "work_history", [
["work_id", "fk:works"],
["before", "t"],
["after", "t"],
["actor", "t"],
["created_at", "t"],
]);
await create(
db,
"entries",
[
["user_id", "fk:users"],
["work_id", "fk:works"],
["status", "t"],
["rating", "i?"],
["review", "t?"],
["private_notes", "t?"],
["favorite", "i"],
["tags", "t"],
["custom_fields", "t"],
["visibility", "t"],
["overrides", "t"],
["media_type_override", "t?"],
["field_sources", "t"],
["created_at", "t"],
["updated_at", "t"],
["deleted_at", "t?"],
],
{ unique: [["user_id", "work_id"]] },
);
await create(db, "engagements", [
["entry_id", "fk:entries"],
["started_at", "t?"],
["started_precision", "t"],
["finished_at", "t?"],
["finished_precision", "t"],
["progress", "t"],
["platform", "t?"],
["rating", "i?"],
["notes", "t?"],
["custom_fields", "t"],
["field_sources", "t"],
["created_at", "t"],
["updated_at", "t"],
["deleted_at", "t?"],
]);
await create(
db,
"source_connections",
[
["user_id", "fk:users"],
["plugin_id", "t"],
["name", "t"],
["config", "t"],
["credentials_enc", "t?"],
["schedule_minutes", "i?"],
["cursor", "t?"],
["status", "t"],
["last_run_at", "t?"],
["created_at", "t"],
],
);
await create(
db,
"engagement_sources",
[
["engagement_id", "fk:engagements"],
["connection_id", "fk:source_connections"],
["record_key", "t"],
],
{ unique: [["connection_id", "record_key"]] },
);
await create(
db,
"listens",
[
["user_id", "fk:users"],
["listened_at", "t"],
["artist", "t"],
["track", "t"],
["album", "t?"],
["duration_ms", "i?"],
["mbids", "t"],
["work_id", "fk:works?"],
["source", "t"],
["source_key", "t"],
["created_at", "t"],
],
{ unique: [["user_id", "source", "source_key"]], indexes: [["user_id", "listened_at"]] },
);
await create(db, "lists", [
["user_id", "fk:users"],
["name", "t"],
["description", "t"],
["kind", "t"],
["visibility", "t"],
["rating", "i?"],
["review", "t?"],
["created_at", "t"],
["updated_at", "t"],
["deleted_at", "t?"],
]);
await create(db, "list_items", [
["list_id", "fk:lists"],
["work_id", "fk:works"],
["position", "i"],
["note", "t?"],
]);
await create(db, "custom_field_definitions", [
["user_id", "fk:users"],
["name", "t"],
["type", "t"],
["options", "t"],
["applies_to", "t"],
["level", "t"],
["created_at", "t"],
]);
await create(db, "copies", [
["user_id", "fk:users"],
["work_id", "fk:works?"],
["title_hint", "t?"],
["format", "t?"],
["acquired_from", "t?"],
["acquired_at", "t?"],
["acquired_precision", "t"],
["price_cents", "i?"],
["currency", "t?"],
["notes", "t?"],
["custom_fields", "t"],
["source_ref", "t?"],
["created_at", "t"],
["updated_at", "t"],
["deleted_at", "t?"],
]);
await create(
db,
"attachments",
[
["user_id", "fk:users"],
["sha256", "t"],
["mime", "t"],
["size", "i"],
["filename", "t"],
["caption", "t?"],
["taken_at", "t?"],
["visibility", "t?"],
["created_at", "t"],
],
{ indexes: [["sha256"]] },
);
await create(
db,
"attachment_links",
[
["attachment_id", "fk:attachments"],
["entity", "t"],
["entity_id", "t"],
],
{ indexes: [["entity", "entity_id"]] },
);
await create(
db,
"user_history",
[
["user_id", "fk:users"],
["entity", "t"],
["entity_id", "t"],
["before", "t"],
["after", "t"],
["actor", "t"],
["created_at", "t"],
],
{ indexes: [["entity", "entity_id"]] },
);
await create(db, "match_suggestions", [
["user_id", "fk:users"],
["work_id", "fk:works"],
["candidate_work_id", "fk:works"],
["score", "r"],
["status", "t"],
["created_at", "t"],
]);
await create(db, "source_runs", [
["connection_id", "fk:source_connections"],
["mode", "t"],
["started_at", "t"],
["finished_at", "t?"],
["status", "t"],
["counts", "t"],
["log", "t"],
["error", "t?"],
]);
await create(
db,
"source_records",
[
["connection_id", "fk:source_connections"],
["key", "t"],
["kind", "t"],
["current_hash", "t"],
["first_seen", "t"],
["last_seen", "t"],
["deleted_at", "t?"],
["merged_into_key", "t?"],
["target_entity", "t?"],
["target_id", "t?"],
],
{ unique: [["connection_id", "key"]], indexes: [["target_entity", "target_id"]] },
);
await create(db, "source_record_versions", [
["record_id", "fk:source_records"],
["hash", "t"],
["raw_gz", "t"],
["seen_at", "t"],
]);
await create(
db,
"change_events",
[
["user_id", "fk:users"],
["connection_id", "fk:source_connections?"],
["run_id", "fk:source_runs?"],
["record_id", "fk:source_records?"],
["type", "t"],
["target_entity", "t"],
["target_id", "t?"],
["payload", "t"],
["status", "t"],
["created_at", "t"],
["resolved_at", "t?"],
],
{ indexes: [["user_id", "status"]] },
);
await create(
db,
"jobs",
[
["type", "t"],
["payload", "t"],
["run_at", "t"],
["status", "t"],
["attempts", "i"],
["last_error", "t?"],
["locked_at", "t?"],
["lock_key", "t?"],
["created_at", "t"],
],
{ indexes: [["status", "run_at"], ["lock_key"]] },
);
},
async down(db: Kysely<any>) {
for (const table of [
"jobs",
"change_events",
"source_record_versions",
"source_records",
"source_runs",
"match_suggestions",
"user_history",
"attachment_links",
"attachments",
"copies",
"custom_field_definitions",
"list_items",
"lists",
"listens",
"engagement_sources",
"source_connections",
"engagements",
"entries",
"work_history",
"work_aliases",
"external_ids",
"works",
"app_tokens",
"invites",
"sessions",
"users",
]) {
await db.schema.dropTable(table).execute();
}
},
};
+4 -1
View File
@@ -1,4 +1,7 @@
import type { Migration } from "kysely/migration";
import { initial } from "./0001_initial.ts";
/** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */
export const migrations: Record<string, Migration> = {};
export const migrations: Record<string, Migration> = {
"0001_initial": initial,
};
+364 -2
View File
@@ -1,2 +1,364 @@
/** Kysely table types. Placeholder until the schema migration lands. */
export interface Database {}
/**
* Kysely table types. snake_case columns; timestamps are ISO-8601 text; JSON columns are text
* (use `toJson`/`fromJson`); booleans are integer 0/1. All IDs come from app code (`newId()`),
* so nothing here is `Generated<>`.
*/
export type Role = "admin" | "member";
export type EntryStatus = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
export type Visibility = "private" | "friends" | "public";
export type TokenScope = "read" | "write" | "ingest";
export type CustomFieldLevel = "entry" | "engagement" | "copy";
export type ListKind = "list" | "grouping" | "thread";
export type AttachmentEntity = "entry" | "engagement" | "copy" | "list" | "work";
export type SuggestionStatus = "pending" | "accepted" | "rejected";
export type ConnectionStatus = "active" | "paused" | "error";
export type RunMode = "full" | "changes" | "file";
export type RunStatus = "running" | "ok" | "failed";
export type ChangeType = "added" | "changed" | "removed" | "merged" | "conflict";
export type ChangeStatus = "pending" | "auto_applied" | "accepted" | "ignored" | "kept_mine";
export type JobStatus = "queued" | "running" | "done" | "failed";
export interface UsersTable {
id: string;
username: string;
display_name: string;
password_hash: string;
role: Role;
preferences: string;
created_at: string;
updated_at: string;
}
export interface SessionsTable {
id: string;
user_id: string;
token_hash: string;
expires_at: string;
created_at: string;
}
export interface InvitesTable {
id: string;
code_hash: string;
created_by: string;
used_by: string | null;
expires_at: string;
created_at: string;
}
export interface AppTokensTable {
id: string;
user_id: string;
name: string;
token_hash: string;
/** JSON `TokenScope[]`. */
scopes: string;
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
}
export interface WorksTable {
id: string;
media_type: string;
title: string;
original_title: string;
year: number | null;
release_date: string | null;
creators: string;
description: string;
cover_url: string;
cover_attachment_id: string | null;
metadata: string;
parent_id: string | null;
position: string | null;
owner_user_id: string | null;
merged_into_id: string | null;
created_at: string;
updated_at: string;
}
export interface ExternalIdsTable {
id: string;
work_id: string;
namespace: string;
value: string;
}
export interface WorkAliasesTable {
id: string;
user_id: string;
from_work_id: string;
to_work_id: string;
}
export interface WorkHistoryTable {
id: string;
work_id: string;
before: string;
after: string;
actor: string;
created_at: string;
}
export interface EntriesTable {
id: string;
user_id: string;
work_id: string;
status: EntryStatus;
rating: number | null;
review: string | null;
private_notes: string | null;
/** 0 or 1. */
favorite: number;
tags: string;
custom_fields: string;
visibility: Visibility;
overrides: string;
media_type_override: string | null;
field_sources: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface EngagementsTable {
id: string;
entry_id: string;
started_at: string | null;
started_precision: string;
finished_at: string | null;
finished_precision: string;
progress: string;
platform: string | null;
rating: number | null;
notes: string | null;
custom_fields: string;
field_sources: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface EngagementSourcesTable {
id: string;
engagement_id: string;
connection_id: string;
record_key: string;
}
export interface ListensTable {
id: string;
user_id: string;
listened_at: string;
artist: string;
track: string;
album: string | null;
duration_ms: number | null;
mbids: string;
work_id: string | null;
source: string;
source_key: string;
created_at: string;
}
export interface ListsTable {
id: string;
user_id: string;
name: string;
description: string;
kind: ListKind;
visibility: Visibility;
rating: number | null;
review: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface ListItemsTable {
id: string;
list_id: string;
work_id: string;
position: number;
note: string | null;
}
export interface CustomFieldDefinitionsTable {
id: string;
user_id: string;
name: string;
type: string;
options: string;
/** JSON `MediaType[] | "all"`. */
applies_to: string;
level: CustomFieldLevel;
created_at: string;
}
export interface CopiesTable {
id: string;
user_id: string;
work_id: string | null;
title_hint: string | null;
format: string | null;
acquired_from: string | null;
acquired_at: string | null;
acquired_precision: string;
price_cents: number | null;
currency: string | null;
notes: string | null;
custom_fields: string;
source_ref: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface AttachmentsTable {
id: string;
user_id: string;
sha256: string;
mime: string;
size: number;
filename: string;
caption: string | null;
taken_at: string | null;
visibility: Visibility | null;
created_at: string;
}
export interface AttachmentLinksTable {
id: string;
attachment_id: string;
entity: AttachmentEntity;
entity_id: string;
}
export interface UserHistoryTable {
id: string;
user_id: string;
entity: string;
entity_id: string;
before: string;
after: string;
actor: string;
created_at: string;
}
export interface MatchSuggestionsTable {
id: string;
user_id: string;
work_id: string;
candidate_work_id: string;
score: number;
status: SuggestionStatus;
created_at: string;
}
export interface SourceConnectionsTable {
id: string;
user_id: string;
plugin_id: string;
name: string;
config: string;
credentials_enc: string | null;
schedule_minutes: number | null;
cursor: string | null;
status: ConnectionStatus;
last_run_at: string | null;
created_at: string;
}
export interface SourceRunsTable {
id: string;
connection_id: string;
mode: RunMode;
started_at: string;
finished_at: string | null;
status: RunStatus;
counts: string;
log: string;
error: string | null;
}
export interface SourceRecordsTable {
id: string;
connection_id: string;
key: string;
kind: string;
current_hash: string;
first_seen: string;
last_seen: string;
deleted_at: string | null;
merged_into_key: string | null;
target_entity: string | null;
target_id: string | null;
}
export interface SourceRecordVersionsTable {
id: string;
record_id: string;
hash: string;
/** Base64 of gzipped raw record. */
raw_gz: string;
seen_at: string;
}
export interface ChangeEventsTable {
id: string;
user_id: string;
connection_id: string | null;
run_id: string | null;
record_id: string | null;
type: ChangeType;
target_entity: string;
target_id: string | null;
payload: string;
status: ChangeStatus;
created_at: string;
resolved_at: string | null;
}
export interface JobsTable {
id: string;
type: string;
payload: string;
run_at: string;
status: JobStatus;
attempts: number;
last_error: string | null;
locked_at: string | null;
lock_key: string | null;
created_at: string;
}
export interface Database {
users: UsersTable;
sessions: SessionsTable;
invites: InvitesTable;
app_tokens: AppTokensTable;
works: WorksTable;
external_ids: ExternalIdsTable;
work_aliases: WorkAliasesTable;
work_history: WorkHistoryTable;
entries: EntriesTable;
engagements: EngagementsTable;
engagement_sources: EngagementSourcesTable;
listens: ListensTable;
lists: ListsTable;
list_items: ListItemsTable;
custom_field_definitions: CustomFieldDefinitionsTable;
copies: CopiesTable;
attachments: AttachmentsTable;
attachment_links: AttachmentLinksTable;
user_history: UserHistoryTable;
match_suggestions: MatchSuggestionsTable;
source_connections: SourceConnectionsTable;
source_runs: SourceRunsTable;
source_records: SourceRecordsTable;
source_record_versions: SourceRecordVersionsTable;
change_events: ChangeEventsTable;
jobs: JobsTable;
}
+44 -19
View File
@@ -1,5 +1,5 @@
import { SQL } from "bun";
import { afterAll, afterEach, beforeEach, describe } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe } from "bun:test";
import { createDb, type Db } from "./db.ts";
import type { Dialect } from "./dialect.ts";
import { migrateToLatest } from "./migrate.ts";
@@ -17,33 +17,50 @@ export function forEachDialect(name: string, fn: (getDb: () => Db) => void): voi
for (const dialect of dialects) {
describe(`${name} [${dialect}]`, () => {
let current: Db | undefined;
const created: string[] = [];
let currentName: string | undefined;
const template = `st_tpl_${crypto.randomUUID().replaceAll("-", "")}`;
if (dialect === "postgres") {
// Migrate one template per describe block, then clone it per test (much faster).
beforeAll(async () => {
await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${template}"`));
const tpl = await createDb(urlFor(pgUrl!, template));
try {
await migrateToLatest(tpl);
} finally {
await tpl.close();
}
}, HOOK_TIMEOUT_MS);
}
beforeEach(async () => {
let url = "sqlite://:memory:";
if (dialect === "postgres") {
const dbName = `st_${crypto.randomUUID().replaceAll("-", "")}`;
await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${dbName}"`));
created.push(dbName);
const u = new URL(pgUrl!);
u.pathname = `/${dbName}`;
url = u.toString();
currentName = `st_${crypto.randomUUID().replaceAll("-", "")}`;
await withAdmin(pgUrl!, (admin) =>
admin.unsafe(`create database "${currentName}" template "${template}"`),
);
current = await createDb(urlFor(pgUrl!, currentName));
} else {
current = await createDb("sqlite://:memory:");
await migrateToLatest(current);
}
current = await createDb(url);
await migrateToLatest(current);
});
}, HOOK_TIMEOUT_MS);
afterEach(async () => {
await current?.close();
current = undefined;
});
if (currentName) {
const dbName = currentName;
currentName = undefined;
await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${dbName}" with (force)`));
}
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
if (created.length === 0) return;
await withAdmin(pgUrl!, async (admin) => {
for (const dbName of created) await admin.unsafe(`drop database if exists "${dbName}" with (force)`);
});
});
if (dialect === "postgres") {
afterAll(async () => {
await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${template}" with (force)`));
}, HOOK_TIMEOUT_MS);
}
fn(() => {
if (!current) throw new Error("getDb() called outside a test");
@@ -53,6 +70,14 @@ export function forEachDialect(name: string, fn: (getDb: () => Db) => void): voi
}
}
const HOOK_TIMEOUT_MS = 30_000;
function urlFor(base: string, dbName: string): string {
const u = new URL(base);
u.pathname = `/${dbName}`;
return u.toString();
}
async function withAdmin<T>(url: string, f: (admin: SQL) => Promise<T>): Promise<T> {
const admin = new SQL(url);
try {
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "bun:test";
import { sql, type Kysely } from "kysely";
import { Migrator } from "kysely/migration";
import { newId, nowIso } from "../src/ids.ts";
import { toJson } from "../src/json.ts";
import { migrations } from "../src/migrations/index.ts";
import { forEachDialect } from "../src/testing.ts";
forEachDialect("schema", (getDb) => {
test("all tables exist and unique constraints hold", async () => {
const k = getDb().k;
const now = nowIso();
const userId = newId();
await k
.insertInto("users")
.values({
id: userId,
username: "ada",
display_name: "Ada",
password_hash: "x",
role: "admin",
preferences: toJson({}),
created_at: now,
updated_at: now,
})
.execute();
const workId = newId();
await k
.insertInto("works")
.values({
id: workId,
media_type: "book",
title: "Dune",
original_title: "Dune",
year: 1965,
release_date: null,
creators: toJson([]),
description: "",
cover_url: "",
cover_attachment_id: null,
metadata: toJson({}),
parent_id: null,
position: null,
owner_user_id: null,
merged_into_id: null,
created_at: now,
updated_at: now,
})
.execute();
await k
.insertInto("external_ids")
.values({ id: newId(), work_id: workId, namespace: "isbn", value: "123" })
.execute();
await expect(
k.insertInto("external_ids").values({ id: newId(), work_id: workId, namespace: "isbn", value: "123" }).execute(),
).rejects.toThrow();
const entry = {
user_id: userId,
work_id: workId,
status: "planned" as const,
rating: null,
review: null,
private_notes: null,
favorite: 0,
tags: toJson([]),
custom_fields: toJson({}),
visibility: "private" as const,
overrides: toJson({}),
media_type_override: null,
field_sources: toJson({}),
created_at: now,
updated_at: now,
deleted_at: null,
};
await k.insertInto("entries").values({ id: newId(), ...entry }).execute();
await expect(k.insertInto("entries").values({ id: newId(), ...entry }).execute()).rejects.toThrow();
});
test("migrating down removes the tables", async () => {
const k = getDb().k as Kysely<any>;
const migrator = new Migrator({ db: k, provider: { getMigrations: async () => migrations } });
const { error } = await migrator.migrateDown();
expect(error).toBeUndefined();
const probe = sql`select 1 from users`.execute(k);
await expect(probe).rejects.toThrow();
});
});