merge: task 2 schema
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d392a65aa7
6 files changed
+928
-23
No files matched your search
@@ -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
|
- Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core
|
||||||
internals, and they return `SourceRecord`s instead of writing.
|
internals, and they return `SourceRecord`s instead of writing.
|
||||||
- `packages/*` and `plugins/*` never import from `apps/*`.
|
- `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`).
|
(`day | month | year | unknown`).
|
||||||
- Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness.
|
- 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.
|
- Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { Migration } from "kysely/migration";
|
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. */
|
/** 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
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
import { SQL } from "bun";
|
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 { createDb, type Db } from "./db.ts";
|
||||||
import type { Dialect } from "./dialect.ts";
|
import type { Dialect } from "./dialect.ts";
|
||||||
import { migrateToLatest } from "./migrate.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) {
|
for (const dialect of dialects) {
|
||||||
describe(`${name} [${dialect}]`, () => {
|
describe(`${name} [${dialect}]`, () => {
|
||||||
let current: Db | undefined;
|
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 () => {
|
beforeEach(async () => {
|
||||||
let url = "sqlite://:memory:";
|
|
||||||
if (dialect === "postgres") {
|
if (dialect === "postgres") {
|
||||||
const dbName = `st_${crypto.randomUUID().replaceAll("-", "")}`;
|
currentName = `st_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||||
await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${dbName}"`));
|
await withAdmin(pgUrl!, (admin) =>
|
||||||
created.push(dbName);
|
admin.unsafe(`create database "${currentName}" template "${template}"`),
|
||||||
const u = new URL(pgUrl!);
|
);
|
||||||
u.pathname = `/${dbName}`;
|
current = await createDb(urlFor(pgUrl!, currentName));
|
||||||
url = u.toString();
|
} else {
|
||||||
|
current = await createDb("sqlite://:memory:");
|
||||||
|
await migrateToLatest(current);
|
||||||
}
|
}
|
||||||
current = await createDb(url);
|
}, HOOK_TIMEOUT_MS);
|
||||||
await migrateToLatest(current);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await current?.close();
|
await current?.close();
|
||||||
current = undefined;
|
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 (dialect === "postgres") {
|
||||||
if (created.length === 0) return;
|
afterAll(async () => {
|
||||||
await withAdmin(pgUrl!, async (admin) => {
|
await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${template}" with (force)`));
|
||||||
for (const dbName of created) await admin.unsafe(`drop database if exists "${dbName}" with (force)`);
|
}, HOOK_TIMEOUT_MS);
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
|
||||||
fn(() => {
|
fn(() => {
|
||||||
if (!current) throw new Error("getDb() called outside a test");
|
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> {
|
async function withAdmin<T>(url: string, f: (admin: SQL) => Promise<T>): Promise<T> {
|
||||||
const admin = new SQL(url);
|
const admin = new SQL(url);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user