Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions src/services/extensions/v2/authors-database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { DatabaseResult, IDatabase } from "../../../lib/interfaces";
import { databaseError } from "./errors";
import { Author, AuthorProfile } from "./interfaces";

// Matches the SQLite/D1 message for the idx_authors_owner_unique violation,
// which is how a lost race between two concurrent first-time PUT /authors/me
// requests (same caller, different ids) surfaces.
function isOwnerConflict(message: string | undefined): boolean {
return !!message && /UNIQUE constraint failed.*owner_user_id/i.test(message);
}

function parseAuthorRow(row: Record<string, unknown>): AuthorProfile {
return {
id: row.id as string,
type: row.type as AuthorProfile["type"],
name: row.name as string,
URL: (row.url as string | null) ?? undefined,
bio: (row.bio as string | null) ?? undefined,
avatar_url: (row.avatar_url as string | null) ?? undefined,
contact_email: (row.contact_email as string | null) ?? undefined,
approved: row.approved_at !== null && row.approved_at !== undefined
};
}

export class AuthorsDatabase {
private db: IDatabase;

constructor(db: IDatabase) {
this.db = db;
}

async upsertOwn(
userId: string,
author: Author
): Promise<DatabaseResult<AuthorProfile>> {
try {
const existingOwn = await this.db
.prepare("SELECT * FROM authors WHERE owner_user_id = ?")
.bind(userId)
.first<Record<string, unknown>>();

const existingById = await this.db
.prepare("SELECT * FROM authors WHERE id = ?")
.bind(author.id)
.first<Record<string, unknown>>();

if (!existingOwn) {
if (existingById) {
return {
data: null,
error: { message: "Author id already exists", code: "CONFLICT" }
};
}

let result;
try {
result = await this.db
.prepare(
`INSERT INTO authors (id, type, name, url, bio, avatar_url, contact_email, owner_user_id, approved_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
)
.bind(
author.id,
author.type,
author.name,
author.URL ?? null,
author.bio ?? null,
author.avatar_url ?? null,
author.contact_email ?? null,
userId
)
.run();
} catch (error) {
if (isOwnerConflict(error instanceof Error ? error.message : "")) {
return {
data: null,
error: {
message: "You already have a developer profile",
code: "CONFLICT"
}
};
}
throw error;
}

if (!result.success) {
if (isOwnerConflict(result.error)) {
return {
data: null,
error: {
message: "You already have a developer profile",
code: "CONFLICT"
}
};
}
return databaseError(
"upsertOwn",
new Error(result.error || "Database query failed")
);
}
} else {
if (author.id !== existingOwn.id) {
return {
data: null,
error: {
message: "Author id cannot be changed",
code: "CONFLICT"
}
};
}

// approved_at is always cleared here, even if nothing meaningful
// changed — the reviewed content just got overwritten, so the old
// approval no longer applies. Not worth diffing old vs. new values.
const result = await this.db
.prepare(
`UPDATE authors SET type = ?, name = ?, url = ?, bio = ?, avatar_url = ?, contact_email = ?, approved_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
)
.bind(
author.type,
author.name,
author.URL ?? null,
author.bio ?? null,
author.avatar_url ?? null,
author.contact_email ?? null,
author.id
)
.run();

if (!result.success) {
return databaseError(
"upsertOwn",
new Error(result.error || "Database query failed")
);
}
}

return this.getById(author.id);
} catch (error) {
return databaseError("upsertOwn", error);
}
}

private async getById(id: string): Promise<DatabaseResult<AuthorProfile>> {
try {
const row = await this.db
.prepare("SELECT * FROM authors WHERE id = ?")
.bind(id)
.first<Record<string, unknown>>();
if (!row) {
return {
data: null,
error: {
message: `Cannot find author by id: ${id}`,
code: "NOT_FOUND"
}
};
}
return { data: parseAuthorRow(row), error: null };
} catch (error) {
return databaseError("getById", error);
}
}

async listUnapproved(): Promise<DatabaseResult<AuthorProfile[]>> {
let result;
try {
result = await this.db
.prepare(
"SELECT * FROM authors WHERE approved_at IS NULL ORDER BY created_at ASC"
)
.all<Record<string, unknown>>();
} catch (error) {
return databaseError("listUnapproved", error);
}

if (!result.success) {
return databaseError(
"listUnapproved",
new Error(result.error || "Database query failed")
);
}

return {
data: (result.results ?? []).map(parseAuthorRow),
error: null
};
}

async approve(
id: string
): Promise<DatabaseResult<{ id: string; approved: true }>> {
let result;
try {
result = await this.db
.prepare(
"UPDATE authors SET approved_at = CURRENT_TIMESTAMP WHERE id = ?"
)
.bind(id)
.run();
} catch (error) {
return databaseError("approve", error);
}

if (!result.success) {
return databaseError(
"approve",
new Error(result.error || "Database query failed")
);
}

if (!result.meta?.changes) {
return {
data: null,
error: { message: `Cannot find author by id: ${id}`, code: "NOT_FOUND" }
};
}

return { data: { id, approved: true }, error: null };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- v2: direct (unmoderated) developer-profile writes, with a moderator-set
-- "approved" trust flag. Adds to the v1-owned `authors` table.
--
-- SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults (including
-- CURRENT_TIMESTAMP), so created_at/updated_at are added with a placeholder
-- default and backfilled immediately after. New rows always set these
-- explicitly (see authors-database.ts), so the placeholder is never seen
-- outside of this migration.

ALTER TABLE authors ADD COLUMN approved_at TEXT;
ALTER TABLE authors ADD COLUMN created_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z';
Comment thread
admdly marked this conversation as resolved.
ALTER TABLE authors ADD COLUMN updated_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z';

UPDATE authors SET created_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP;

CREATE INDEX IF NOT EXISTS idx_authors_approved ON authors(approved_at);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- v2: additional developer-profile fields for the public /developer/{id}
-- page (bio, avatar_url) and moderator/maintainer contact (contact_email,
-- never exposed on public reads). Adds to the v1-owned `authors` table.

ALTER TABLE authors ADD COLUMN bio TEXT;
ALTER TABLE authors ADD COLUMN avatar_url TEXT;
ALTER TABLE authors ADD COLUMN contact_email TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- v2: enforce one profile per owner at the database level. Without this,
-- two concurrent first-time PUT /authors/me requests with different ids
-- (same caller) could both pass the app-level "do I already have a
-- profile?" check and both insert, leaving the caller with two profiles.
-- NULL owner_user_id (pre-v2 rows) is exempt: SQLite never treats NULLs as
-- equal in a unique index, so legacy unowned authors can coexist freely.
--
-- The race this closes predates this migration, so a database may already
-- have duplicate owner_user_id rows — CREATE UNIQUE INDEX would fail on
-- those and block every migration after it. Detach ownership (not delete)
-- from all but the most recently created row per owner first, so the
-- constraint can always be created; any detached profile becomes unowned,
-- same as a legacy pre-v2 row, and needs manual reconciliation.
UPDATE authors
SET owner_user_id = NULL
WHERE owner_user_id IS NOT NULL
AND rowid NOT IN (
SELECT MAX(rowid) FROM authors
WHERE owner_user_id IS NOT NULL
GROUP BY owner_user_id
);

DROP INDEX IF EXISTS idx_authors_owner;
CREATE UNIQUE INDEX IF NOT EXISTS idx_authors_owner_unique ON authors(owner_user_id);
Comment thread
admdly marked this conversation as resolved.
Loading
Loading