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
24 changes: 24 additions & 0 deletions src/lib/releases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { rcompare, valid } from "semver";

export interface ReleaseTag {
tag: string;
}

// Newest first by semver. Tags that aren't valid semver can't be ordered
// relative to the valid ones without risking a non-transitive comparator —
// e.g. an invalid tag comparing "equal" (via a caught exception) to two
// valid tags that are themselves not equal breaks Array#sort's assumption
// of a total order, and can leave later valid entries out of order even
// though every valid-to-valid comparison alone would have been correct.
// Partitioning the invalid tags out before sorting avoids that entirely;
// they're appended after, in their original relative order.
export function sortReleasesDescending<T extends ReleaseTag>(
releases: T[]
): T[] {
const withValidTag = releases.filter((r) => valid(r.tag) !== null);
const withInvalidTag = releases.filter((r) => valid(r.tag) === null);
return [
...withValidTag.sort((a, b) => rcompare(a.tag, b.tag)),
...withInvalidTag
];
}
18 changes: 4 additions & 14 deletions src/services/extensions/v1/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { gt, lt } from "semver";
import { gt } from "semver";
import { sortReleasesDescending } from "../../../lib/releases";

export { sortReleasesDescending };

export type Extension = {
id: string;
Expand Down Expand Up @@ -74,16 +77,3 @@ export function getLatestRelease(extension: Extension): Release | undefined {

return latestRelease;
}

export function sortReleasesDescending(releases: Release[]): Release[] {
return [...releases].sort((a, b) => {
try {
if (gt(a.tag, b.tag)) return -1;
if (lt(a.tag, b.tag)) return 1;
return 0;
} catch {
// Keep relative order when tags can't be compared as semver
return 0;
}
});
}
2 changes: 1 addition & 1 deletion src/services/extensions/v2/developers-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class DevelopersDatabase {
}
}

private async getById(id: string): Promise<DatabaseResult<DeveloperProfile>> {
async getById(id: string): Promise<DatabaseResult<DeveloperProfile>> {
try {
const row = await this.db
.prepare("SELECT * FROM developers WHERE id = ?")
Expand Down
148 changes: 148 additions & 0 deletions src/services/extensions/v2/extensions-database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { DatabaseResult, IDatabase } from "../../../lib/interfaces";
import { databaseError } from "./errors";
import {
Extension,
License,
Release,
Repository,
sortReleasesDescending
} from "./interfaces";

// LEFT JOIN so an extension whose developer row is missing (author_id
// pointing nowhere) still lists — author_id isn't a hard FK (see
// 0001_add_v2_tables.sql). COALESCE keeps developer_id non-null in that
// case: e.author_id is itself NOT NULL, so the id half of the embedded
// developer is never lost even when every other field falls back to a
// default in parseExtensionRow below.
const SELECT_EXTENSIONS = `
SELECT e.id, e.type, e.name, e.description, e.releases, e.website, e.license,
e.icon_url, e.readme, e.source, e.version, e.download_url,
COALESCE(d.id, e.author_id) AS developer_id,
d.type AS developer_type, d.name AS developer_name,
d.url AS developer_url, d.bio AS developer_bio,
d.avatar_url AS developer_avatar_url, d.approved_at AS developer_approved_at
FROM extensions e
LEFT JOIN developers d ON e.author_id = d.id
`;

export interface ExtensionListFilters {
type?: string;
developerId?: string;
}

export class ExtensionsDatabase {
private db: IDatabase;

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

async list(
filters: ExtensionListFilters = {}
): Promise<DatabaseResult<Extension[]>> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filters.type) {
conditions.push("e.type = ?");
params.push(filters.type);
}
if (filters.developerId) {
conditions.push("e.author_id = ?");
params.push(filters.developerId);
}
const query = conditions.length
? `${SELECT_EXTENSIONS} WHERE ${conditions.join(" AND ")}`
: SELECT_EXTENSIONS;

let result;
try {
result = await this.db
.prepare(query)
.bind(...params)
.all<Record<string, unknown>>();
} catch (error) {
return databaseError("list", error);
}

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

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

async getById(id: string): Promise<DatabaseResult<Extension>> {
let row;
try {
row = await this.db
.prepare(`${SELECT_EXTENSIONS} WHERE LOWER(e.id) = LOWER(?)`)
.bind(id)
.first<Record<string, unknown>>();
} catch (error) {
return databaseError("getById", error);
}

if (!row) {
return {
data: null,
error: {
message: `Cannot find extension by id: ${id}`,
code: "NOT_FOUND"
}
};
}

return { data: parseExtensionRow(row), error: null };
}
}

function parseJSON<T>(value: unknown, fallback: T): T {
if (typeof value === "string") {
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
return value !== undefined && value !== null ? (value as T) : fallback;
}

function parseExtensionRow(row: Record<string, unknown>): Extension {
const releases = parseJSON<Release[]>(row.releases, []);
return {
id: row.id as string,
type: row.type as Extension["type"],
name: row.name as string,
description: row.description as string,
releases: sortReleasesDescending(releases),
website: row.website as string,
license: parseJSON<License>(row.license, { name: "" }),
icon_url: typeof row.icon_url === "string" ? row.icon_url : undefined,
readme: row.readme as string,
source: parseJSON<Repository>(row.source, { type: "custom", repo: "" }),
version: row.version as string,
download_url: row.download_url as string,
developer: {
id: row.developer_id as string,
type: (row.developer_type as "user" | "organization") ?? "user",
name: (row.developer_name as string) ?? "",
URL:
typeof row.developer_url === "string" ? row.developer_url : undefined,
bio:
typeof row.developer_bio === "string" ? row.developer_bio : undefined,
avatar_url:
typeof row.developer_avatar_url === "string"
? row.developer_avatar_url
: undefined,
approved:
row.developer_approved_at !== null &&
row.developer_approved_at !== undefined
}
};
}
Loading
Loading