/* global console, fetch, process, setTimeout */ import { existsSync, readFileSync } from "node:fs"; const env = loadEnvFiles([".env", ".env.local"]); const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.io/v1").replace(/\/$/, ""); const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138"); const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker"); const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries"); const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats"); const apiKey = readEnv("APPWRITE_API_KEY", ""); if (!apiKey) { throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); } await ensureDatabase(databaseId, "Red Bull Tracker"); await ensureTable({ tableId: intakeTableId, name: "Intake entries", columns: [ { kind: "string", key: "userId", size: 64, required: true }, { kind: "float", key: "cans", required: true }, { kind: "string", key: "flavour", size: 128, required: true }, { kind: "string", key: "flavourAccent", size: 32, required: true }, { kind: "integer", key: "sizeMl", required: true }, { kind: "float", key: "pricePerCan", required: true }, { kind: "datetime", key: "dateTime", required: true }, { kind: "string", key: "notes", size: 2000, required: false }, { kind: "string", key: "store", size: 256, required: false }, { kind: "boolean", key: "sugarFree", required: true }, { kind: "float", key: "caffeineMgPerCan", required: false }, { kind: "string", key: "importKey", size: 512, required: true }, { kind: "string", key: "source", size: 32, required: true }, ], indexes: [ { key: "user_date_desc", type: "key", columns: ["userId", "dateTime"], orders: ["ASC", "DESC"], lengths: [32] }, { key: "user_import_key", type: "key", columns: ["userId", "importKey"], orders: ["ASC", "ASC"], lengths: [32, 128] }, ], }); await ensureTable({ tableId: chatTableId, name: "Coach chats", columns: [ { kind: "string", key: "userId", size: 64, required: true }, { kind: "string", key: "title", size: 512, required: true }, { kind: "longtext", key: "messages", required: true }, { kind: "datetime", key: "updatedAt", required: true }, ], indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], }); await retireLegacyChatColumns(chatTableId, [ "encryptedTitle", "encryptedMessages", "titleIv", "messagesIv", "salt", "version", ]); await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]); console.log("Appwrite database and tables ready."); async function ensureDatabase(id, name) { const existing = await request("GET", `/tablesdb/${id}`, undefined, [200, 404]); if (existing.status === 200) { console.log(`Database ${id} exists.`); return; } await request("POST", "/tablesdb", { databaseId: id, name, enabled: true }, [201]); console.log(`Database ${id} created.`); } async function ensureTable({ tableId, name, columns, indexes }) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}`, undefined, [200, 404]); if (existing.status === 404) { await request( "POST", `/tablesdb/${databaseId}/tables`, { tableId, name, permissions: ['create("users")'], rowSecurity: true, enabled: true, }, [201], ); console.log(`Table ${tableId} created.`); } else { await request( "PUT", `/tablesdb/${databaseId}/tables/${tableId}`, { name, permissions: ['create("users")'], rowSecurity: true, enabled: true, purge: true }, [200], ); console.log(`Table ${tableId} exists and permissions updated.`); } for (const column of columns) { await ensureColumn(tableId, column); } await waitForColumns(tableId, columns.map((column) => column.key)); for (const index of indexes) { await ensureIndex(tableId, index); } } async function ensureColumn(tableId, column) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.key}`, undefined, [200, 404]); if (existing.status === 200) { console.log(`Column ${tableId}.${column.key} exists.`); return; } const body = { key: column.key, required: column.required, array: false, }; if (column.size) body.size = column.size; if (column.encrypt) body.encrypt = true; await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]); console.log(`Column ${tableId}.${column.key} created.`); } async function retireLegacyChatColumns(tableId, keys) { for (const key of keys) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]); if (existing.status === 404) { console.log(`Legacy column ${tableId}.${key} already removed.`); continue; } await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]); console.log(`Legacy column ${tableId}.${key} removed.`); } } async function ensureIndex(tableId, index) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); if (existing.status === 200) { console.log(`Index ${tableId}.${index.key} exists.`); return; } await request( "POST", `/tablesdb/${databaseId}/tables/${tableId}/indexes`, { key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths }, [202, 201], ); console.log(`Index ${tableId}.${index.key} created.`); } async function waitForColumns(tableId, keys) { const pending = new Set(keys); for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) { for (const key of [...pending]) { const response = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]); if (response.status === 200 && ["available", "failed"].includes(response.body.status)) { if (response.body.status === "failed") throw new Error(`Column ${tableId}.${key} failed: ${response.body.error || "unknown error"}`); pending.delete(key); } } if (pending.size) await delay(1_000); } if (pending.size) throw new Error(`Timed out waiting for columns: ${[...pending].join(", ")}`); } async function request(method, path, body, okStatuses) { const response = await fetch(`${endpoint}${path}`, { method, headers: { "Content-Type": "application/json", "X-Appwrite-Key": apiKey, "X-Appwrite-Project": projectId, }, body: body ? JSON.stringify(body) : undefined, }); const text = await response.text(); const parsed = text ? parseJson(text) : null; if (!okStatuses.includes(response.status)) { const message = parsed?.message || text || `${method} ${path} failed with status ${response.status}`; throw new Error(message); } return { status: response.status, body: parsed }; } function parseJson(value) { try { return JSON.parse(value); } catch { return null; } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function readEnv(name, fallback) { return process.env[name] || env[name] || fallback; } function loadEnvFiles(paths) { const values = {}; for (const path of paths) { if (!existsSync(path)) continue; const lines = readFileSync(path, "utf8").split(/\r?\n/); for (const line of lines) { const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue; values[match[1]] = match[2].trim().replace(/^(["'])(.*)\1$/, "$2"); } } return values; }