feat(db): initial schema
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
27ec24eb4b
commit
da04adb286
@@ -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.
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
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"]] },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+363
-2
@@ -1,2 +1,363 @@
|
||||
/** 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 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: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { newId, nowIso } from "../src/ids.ts";
|
||||
import { toJson } from "../src/json.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",
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user