initial commit

This commit is contained in:
Vuk Savić
2026-03-16 21:38:49 +01:00
commit d1eeccbefc
21 changed files with 3178 additions and 0 deletions

11
server/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "guildlib-server",
"version": "2.0.0",
"module": "src/index.ts",
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts"
},
"devDependencies": { "@types/bun": "latest" }
}

68
server/src/index.ts Normal file
View File

@@ -0,0 +1,68 @@
import { serve } from "bun";
import { ShardManager } from "./shardManager";
import { manifestHandler } from "./routes/manifest";
import { pullHandler } from "./routes/pull";
import { pushHandler } from "./routes/push";
import { schemaHandler } from "./routes/schema";
import { entriesHandler } from "./routes/entries";
import { authMiddleware } from "./middleware/auth";
const PORT = Number(Bun.env.PORT ?? 3000);
const API_KEY = Bun.env.GUILDLIB_API_KEY ?? "";
export const shards = new ShardManager(Bun.env.SHARD_DIR ?? "./shards");
await shards.init();
console.log(`[Guildlib] :${PORT} shards: ${shards.shardNames().join(", ")} auth: ${API_KEY ? "on" : "OFF"}`);
serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
const method = req.method;
if (method === "OPTIONS") return cors(new Response(null, { status: 204 }));
if (path !== "/health" && API_KEY) {
const err = authMiddleware(req, API_KEY);
if (err) return cors(err);
}
if (path === "/health" && method === "GET")
return cors(json({ ok: true, version: "2.0.0", shards: shards.shardNames(), ts: Date.now() }));
if (path.startsWith("/manifest/") && method === "GET")
return cors(await manifestHandler(shards, path.split("/")[2]));
if (path === "/pull" && method === "POST") return cors(await pullHandler(shards, req));
if (path === "/push" && method === "POST") return cors(await pushHandler(shards, req));
if (path === "/schema") {
if (method === "POST") return cors(await schemaHandler.post(req));
if (method === "GET") return cors(await schemaHandler.get());
}
if (path.startsWith("/entries/"))
return cors(await entriesHandler(shards, req, url, path, method));
return cors(new Response("Not found", { status: 404 }));
},
error(e) {
console.error(e);
return cors(json({ error: e.message }, 500));
},
});
export function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status, headers: { "Content-Type": "application/json" }
});
}
export function cors(res: Response) {
res.headers.set("Access-Control-Allow-Origin", "*");
res.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.headers.set("Access-Control-Allow-Headers", "Content-Type, X-Api-Key");
return res;
}

View File

@@ -0,0 +1,8 @@
// middleware/auth.ts
export function authMiddleware(req: Request, key: string): Response | null {
if (req.headers.get("X-Api-Key") !== key)
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401, headers: { "Content-Type": "application/json" }
});
return null;
}

View File

@@ -0,0 +1,97 @@
// routes/entries.ts
import type { ShardManager } from "../shardManager";
import { json } from "../index";
import { randomUUID } from "crypto";
function parseRow(r: any) {
return {
...r,
platforms: JSON.parse(r.platforms || '["all"]'),
tags: JSON.parse(r.tags || "[]"),
data: JSON.parse(r.data_json),
data_json: undefined,
};
}
export async function entriesHandler(
shards: ShardManager, req: Request, url: URL, path: string, method: string
) {
const parts = path.split("/").filter(Boolean);
const shard = parts[1];
const entryId = parts[2];
if (!shard) return json({ error: "Missing shard" }, 400);
// ── List ────────────────────────────────────────────────────────────────
if (!entryId && method === "GET") {
const platforms = url.searchParams.get("platforms")?.split(",").filter(Boolean);
const tags = url.searchParams.get("tags")?.split(",").filter(Boolean);
try {
const rows = shards.listEntries(shard, {
typeName: url.searchParams.get("type") ?? undefined,
parentId: url.searchParams.get("parent") ?? undefined,
platforms, tags,
limit: Number(url.searchParams.get("limit") ?? 100),
offset: Number(url.searchParams.get("offset") ?? 0),
});
return json(rows.map(parseRow));
} catch (e: any) { return json({ error: e.message }, 404); }
}
// ── Create ───────────────────────────────────────────────────────────────
if (!entryId && method === "POST") {
try {
const body = await req.json() as {
typeName: string; parentId?: string;
platforms?: string[]; tags?: string[];
data: Record<string, unknown>;
};
const id = randomUUID().replace(/-/g, "");
const row = shards.upsert(shard, {
id,
type_name: body.typeName,
parent_id: body.parentId ?? null,
platforms: body.platforms ?? ["all"],
tags: body.tags ?? [],
data: { ...body.data, entryId: id },
});
return json({ id: row.id, version: row.row_version }, 201);
} catch (e: any) { return json({ error: e.message }, 400); }
}
// ── Single ───────────────────────────────────────────────────────────────
if (entryId && method === "GET") {
const row = shards.getEntry(shard, entryId);
if (!row) return json({ error: "Not found" }, 404);
return json(parseRow(row));
}
// ── Update ───────────────────────────────────────────────────────────────
if (entryId && method === "PUT") {
const existing = shards.getEntry(shard, entryId);
if (!existing) return json({ error: "Not found" }, 404);
try {
const body = await req.json() as {
platforms?: string[]; tags?: string[];
data?: Record<string, unknown>;
};
const row = shards.upsert(shard, {
id: entryId,
type_name: existing.type_name,
parent_id: existing.parent_id,
platforms: body.platforms ?? JSON.parse(existing.platforms || '["all"]'),
tags: body.tags ?? JSON.parse(existing.tags || "[]"),
data: { ...JSON.parse(existing.data_json), ...(body.data ?? {}) },
});
return json({ id: row.id, version: row.row_version });
} catch (e: any) { return json({ error: e.message }, 400); }
}
// ── Delete ───────────────────────────────────────────────────────────────
if (entryId && method === "DELETE") {
try { shards.softDelete(shard, entryId); return json({ ok: true }); }
catch (e: any) { return json({ error: e.message }, 400); }
}
return json({ error: "Method not allowed" }, 405);
}

View File

@@ -0,0 +1,8 @@
// routes/manifest.ts
import type { ShardManager } from "../shardManager";
import { json } from "../index";
export async function manifestHandler(shards: ShardManager, shardName: string) {
try { return json({ shard: shardName, rows: shards.getManifest(shardName) }); }
catch (e: any) { return json({ error: e.message }, 404); }
}

34
server/src/routes/pull.ts Normal file
View File

@@ -0,0 +1,34 @@
// routes/pull.ts
import type { ShardManager } from "../shardManager";
import { json } from "../index";
export async function pullHandler(shards: ShardManager, req: Request) {
try {
const body = await req.json() as {
shard: string;
ids: string[];
filterPlatforms?: string[] | null;
filterTags?: string[] | null;
};
const rows = shards.getRowsByIds(
body.shard,
body.ids,
body.filterPlatforms,
body.filterTags
);
const out = rows.map(r => ({
id: r.id,
type_name: r.type_name,
parent_id: r.parent_id,
row_version: r.row_version,
platforms: JSON.parse(r.platforms || '["all"]'),
tags: JSON.parse(r.tags || "[]"),
deleted: r.deleted,
...JSON.parse(r.data_json),
}));
return json({ shard: body.shard, rows: out });
} catch (e: any) { return json({ error: e.message }, 400); }
}

28
server/src/routes/push.ts Normal file
View File

@@ -0,0 +1,28 @@
// routes/push.ts
import type { ShardManager } from "../shardManager";
import { json } from "../index";
import { randomUUID } from "crypto";
export async function pushHandler(shards: ShardManager, req: Request) {
try {
const body = await req.json() as {
shard: string;
typeName: string;
platforms?: string[];
tags?: string[];
data: Record<string, unknown>;
};
const id = (body.data.entryId as string) || randomUUID().replace(/-/g, "");
const row = shards.upsert(body.shard, {
id,
type_name: body.typeName,
parent_id: (body.data.parentId as string) ?? null,
platforms: body.platforms ?? ["all"],
tags: body.tags ?? [],
data: body.data,
});
return json({ ok: true, id: row.id, version: row.row_version });
} catch (e: any) { return json({ error: e.message }, 400); }
}

View File

@@ -0,0 +1,24 @@
// routes/schema.ts
import { json } from "../index";
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
import { dirname } from "path";
const SCHEMA_PATH = Bun.env.SCHEMA_PATH ?? "./config/schema.json";
export const schemaHandler = {
async post(req: Request) {
try {
const text = await req.text();
JSON.parse(text); // validate JSON
mkdirSync(dirname(SCHEMA_PATH), { recursive: true });
writeFileSync(SCHEMA_PATH, text, "utf8");
return json({ ok: true });
} catch (e: any) { return json({ error: e.message }, 400); }
},
async get() {
if (!existsSync(SCHEMA_PATH)) return json({ error: "No schema yet" }, 404);
return new Response(readFileSync(SCHEMA_PATH, "utf8"), {
headers: { "Content-Type": "application/json" }
});
}
};

256
server/src/shardManager.ts Normal file
View File

@@ -0,0 +1,256 @@
/**
* ShardManager v2
*
* Schema (per shard .sqlite file):
*
* entries (
* id TEXT PRIMARY KEY,
* type_name TEXT NOT NULL,
* parent_id TEXT,
* row_version INTEGER NOT NULL DEFAULT 1,
* checksum TEXT NOT NULL,
* platforms TEXT NOT NULL DEFAULT '["all"]', -- JSON array of platform strings
* tags TEXT NOT NULL DEFAULT '[]', -- JSON array of tag strings
* data_json TEXT NOT NULL,
* created_at INTEGER NOT NULL,
* updated_at INTEGER NOT NULL,
* deleted INTEGER NOT NULL DEFAULT 0
* )
*
* manifest_cache (
* id TEXT PRIMARY KEY,
* row_version INTEGER NOT NULL,
* checksum TEXT NOT NULL
* )
*/
import { Database } from "bun:sqlite";
import { mkdirSync, readdirSync } from "fs";
import { join } from "path";
import { createHash } from "crypto";
export interface ManifestRow {
id: string; version: number; checksum: string;
}
export interface EntryRow {
id: string; type_name: string; parent_id: string | null;
row_version: number; checksum: string;
platforms: string; // stored as JSON string, e.g. '["pc","ps5"]'
tags: string; // stored as JSON string, e.g. '["release","base_game"]'
data_json: string;
created_at: number; updated_at: number; deleted: number;
}
export class ShardManager {
private dir: string;
private dbs: Map<string, Database> = new Map();
constructor(dir: string) { this.dir = dir; }
async init() {
mkdirSync(this.dir, { recursive: true });
// Open any .sqlite files already on disk
for (const file of readdirSync(this.dir)) {
if (file.endsWith(".sqlite"))
this.openShard(file.replace(".sqlite", ""));
}
}
// Opens (or creates) a shard SQLite file
openShard(name: string) {
if (this.dbs.has(name)) return;
const path = join(this.dir, `${name}.sqlite`);
const db = new Database(path, { create: true });
db.exec("PRAGMA journal_mode=WAL;");
db.exec("PRAGMA synchronous=NORMAL;");
db.exec(`
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
type_name TEXT NOT NULL,
parent_id TEXT,
row_version INTEGER NOT NULL DEFAULT 1,
checksum TEXT NOT NULL,
platforms TEXT NOT NULL DEFAULT '["all"]',
tags TEXT NOT NULL DEFAULT '[]',
data_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_type ON entries(type_name);
CREATE INDEX IF NOT EXISTS idx_parent ON entries(parent_id);
CREATE INDEX IF NOT EXISTS idx_version ON entries(row_version);
CREATE INDEX IF NOT EXISTS idx_deleted ON entries(deleted);
CREATE TABLE IF NOT EXISTS manifest_cache (
id TEXT PRIMARY KEY,
row_version INTEGER NOT NULL,
checksum TEXT NOT NULL
);
`);
this.dbs.set(name, db);
console.log(`[ShardManager] Opened: ${name}.sqlite`);
}
shardNames(): string[] { return Array.from(this.dbs.keys()); }
private require(name: string): Database {
if (!this.dbs.has(name)) this.openShard(name); // auto-create new shards
return this.dbs.get(name)!;
}
// ── Manifest ─────────────────────────────────────────────────────────────
getManifest(shardName: string): ManifestRow[] {
return this.require(shardName)
.query("SELECT id, row_version AS version, checksum FROM manifest_cache")
.all() as ManifestRow[];
}
// ── Pull: fetch rows by IDs with optional platform/tag filtering ──────────
getRowsByIds(
shardName: string,
ids: string[],
filterPlatforms?: string[] | null,
filterTags?: string[] | null
): EntryRow[] {
if (ids.length === 0) return [];
const db = this.require(shardName);
const placeholders = ids.map(() => "?").join(",");
// Start with ID list
let rows = db.query(
`SELECT * FROM entries WHERE id IN (${placeholders}) AND deleted = 0`
).all(...ids) as EntryRow[];
// Apply platform filter in JS (SQLite JSON functions vary by version)
if (filterPlatforms && filterPlatforms.length > 0) {
rows = rows.filter(r => {
const plats: string[] = JSON.parse(r.platforms || '["all"]');
return plats.includes("all") ||
plats.some(p => filterPlatforms.includes(p));
});
}
// Apply tag filter
if (filterTags && filterTags.length > 0) {
rows = rows.filter(r => {
const rowTags: string[] = JSON.parse(r.tags || "[]");
return rowTags.length === 0 || // no tags = unrestricted
rowTags.some(t => filterTags.includes(t));
});
}
return rows;
}
// ── Upsert ────────────────────────────────────────────────────────────────
upsert(shardName: string, entry: {
id: string;
type_name: string;
parent_id?: string | null;
platforms?: string[];
tags?: string[];
data: Record<string, unknown>;
}): EntryRow {
const db = this.require(shardName);
const now = Date.now();
const dataJson = JSON.stringify(entry.data);
const checksum = md5(dataJson);
const platforms = JSON.stringify(entry.platforms ?? ["all"]);
const tags = JSON.stringify(entry.tags ?? []);
const existing = db.query(
"SELECT row_version FROM entries WHERE id = ?"
).get(entry.id) as { row_version: number } | null;
const newVersion = (existing?.row_version ?? 0) + 1;
db.query(`
INSERT INTO entries
(id, type_name, parent_id, row_version, checksum, platforms, tags, data_json, created_at, updated_at, deleted)
VALUES
($id, $type_name, $parent_id, $ver, $chk, $plats, $tags, $data, $now, $now, 0)
ON CONFLICT(id) DO UPDATE SET
type_name = $type_name,
parent_id = $parent_id,
row_version = $ver,
checksum = $chk,
platforms = $plats,
tags = $tags,
data_json = $data,
updated_at = $now,
deleted = 0
`).run({
$id: entry.id, $type_name: entry.type_name,
$parent_id: entry.parent_id ?? null,
$ver: newVersion, $chk: checksum,
$plats: platforms, $tags: tags,
$data: dataJson, $now: now,
});
db.query(`
INSERT INTO manifest_cache (id, row_version, checksum)
VALUES ($id, $ver, $chk)
ON CONFLICT(id) DO UPDATE SET row_version=$ver, checksum=$chk
`).run({ $id: entry.id, $ver: newVersion, $chk: checksum });
return db.query("SELECT * FROM entries WHERE id = ?").get(entry.id) as EntryRow;
}
// ── CRUD ──────────────────────────────────────────────────────────────────
listEntries(shardName: string, opts: {
typeName?: string; parentId?: string;
platforms?: string[]; tags?: string[];
limit?: number; offset?: number;
} = {}): EntryRow[] {
const db = this.require(shardName);
const clauses = ["deleted = 0"];
const params: unknown[] = [];
if (opts.typeName) { clauses.push("type_name = ?"); params.push(opts.typeName); }
if (opts.parentId) { clauses.push("parent_id = ?"); params.push(opts.parentId); }
params.push(opts.limit ?? 100, opts.offset ?? 0);
let rows = db.query(
`SELECT * FROM entries WHERE ${clauses.join(" AND ")} ORDER BY updated_at DESC LIMIT ? OFFSET ?`
).all(...params) as EntryRow[];
// Platform filter
if (opts.platforms?.length) {
rows = rows.filter(r => {
const plats: string[] = JSON.parse(r.platforms || '["all"]');
return plats.includes("all") || plats.some(p => opts.platforms!.includes(p));
});
}
// Tag filter
if (opts.tags?.length) {
rows = rows.filter(r => {
const rowTags: string[] = JSON.parse(r.tags || "[]");
return rowTags.length === 0 || rowTags.some(t => opts.tags!.includes(t));
});
}
return rows;
}
getEntry(shardName: string, id: string): EntryRow | null {
return this.require(shardName)
.query("SELECT * FROM entries WHERE id = ? AND deleted = 0")
.get(id) as EntryRow | null;
}
softDelete(shardName: string, id: string) {
const db = this.require(shardName);
const now = Date.now();
db.query("UPDATE entries SET deleted=1, updated_at=?, row_version=row_version+1 WHERE id=?").run(now, id);
const row = db.query("SELECT row_version, checksum FROM entries WHERE id=?").get(id) as any;
if (row) db.query("UPDATE manifest_cache SET row_version=? WHERE id=?").run(row.row_version, id);
}
}
function md5(s: string) { return createHash("md5").update(s).digest("hex"); }