UNPKG

webpods

Version:

Append-only log service with OAuth authentication

282 lines 9.97 kB
/** * Record operations domain logic */ import { calculateRecordHash, isValidName, isNumericIndex } from "../utils.js"; import { createLogger } from "../logger.js"; const logger = createLogger("webpods:domain:records"); /** * Map database row to domain type */ function mapRecordFromDb(row) { return { id: row.id ? parseInt(row.id) : 0, stream_id: row.stream_id, index: row.index, content: row.content, content_type: row.content_type, name: row.name || "", hash: row.hash, previous_hash: row.previous_hash || null, author_id: row.author_id, metadata: undefined, created_at: typeof row.created_at === "string" ? new Date(row.created_at) : row.created_at, }; } /** * Write a record to a stream */ export async function writeRecord(db, streamId, content, contentType, authorId, name) { // Validate name (required) if (!isValidName(name)) { return { success: false, error: { code: "INVALID_NAME", message: "Name can only contain letters, numbers, hyphens, underscores, and periods. Cannot start or end with a period.", }, }; } try { return await db.tx(async (t) => { // Get the previous record for hash chain const previousRecord = await t.oneOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) ORDER BY index DESC LIMIT 1`, { streamId }); const index = (previousRecord?.index ?? -1) + 1; const previousHash = previousRecord?.hash || null; const timestamp = new Date().toISOString(); // Calculate hash const hash = calculateRecordHash(previousHash, timestamp, content); // Prepare content for storage let storedContent = content; if (typeof content === "object" && contentType === "application/json") { storedContent = JSON.stringify(content); } // Insert new record const record = await t.one(`INSERT INTO record (stream_id, index, content, content_type, name, hash, previous_hash, author_id, created_at) VALUES ($(streamId), $(index), $(content), $(contentType), $(name), $(hash), $(previousHash), $(authorId), $(timestamp)) RETURNING *`, { streamId, index, content: storedContent, contentType, name, hash, previousHash, authorId, timestamp, }); logger.info("Record written", { streamId, index, name, hash }); return { success: true, data: mapRecordFromDb(record) }; }); } catch (error) { logger.error("Failed to write record", { error, streamId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to write record", }, }; } } /** * Get a record by index or alias */ export async function getRecord(db, streamId, target, preferName = false) { try { let record = null; // If preferName is true, try name first even if target is numeric if (preferName) { // Try to get by name first - get the latest record with this name record = await db.oneOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) AND name = $(name) ORDER BY index DESC LIMIT 1`, { streamId, name: target }); // If not found as name and target is numeric, try as index if (!record && isNumericIndex(target)) { let index = parseInt(target); // Handle negative indexing if (index < 0) { const countResult = await db.one(`SELECT COUNT(*) as count FROM record WHERE stream_id = $(streamId)`, { streamId }); index = parseInt(countResult.count) + index; if (index < 0) { return { success: false, error: { code: "INVALID_INDEX", message: "Index out of range", }, }; } } record = await db.oneOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) AND index = $(index)`, { streamId, index }); } } else { // Default behavior: check if target is numeric (index) if (isNumericIndex(target)) { let index = parseInt(target); // Handle negative indexing if (index < 0) { const countResult = await db.one(`SELECT COUNT(*) as count FROM record WHERE stream_id = $(streamId)`, { streamId }); index = parseInt(countResult.count) + index; if (index < 0) { return { success: false, error: { code: "INVALID_INDEX", message: "Index out of range", }, }; } } record = await db.oneOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) AND index = $(index)`, { streamId, index }); } else { // Get by name - get the latest record with this name record = await db.oneOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) AND name = $(name) ORDER BY index DESC LIMIT 1`, { streamId, name: target }); } } if (!record) { return { success: false, error: { code: "RECORD_NOT_FOUND", message: "Record not found", }, }; } return { success: true, data: mapRecordFromDb(record) }; } catch (error) { logger.error("Failed to get record", { error, streamId, target }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to get record", }, }; } } /** * Get a range of records */ export async function getRecordRange(db, streamId, start, end) { try { // Get total count for negative index handling const countResult = await db.one(`SELECT COUNT(*) as count FROM record WHERE stream_id = $(streamId)`, { streamId }); const total = parseInt(countResult.count); // Handle negative indices if (start < 0) start = total + start; if (end < 0) end = total + end; // Validate range if (start < 0 || end < 0 || start > end) { return { success: false, error: { code: "INVALID_RANGE", message: "Invalid range specified", }, }; } const records = await db.manyOrNone(`SELECT * FROM record WHERE stream_id = $(streamId) AND index >= $(start) AND index < $(end) ORDER BY index ASC`, { streamId, start, end }); return { success: true, data: records.map(mapRecordFromDb) }; } catch (error) { logger.error("Failed to get record range", { error, streamId, start, end }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to get record range", }, }; } } /** * List records in a stream */ export async function listRecords(db, streamId, limit = 100, after) { try { let query = `SELECT * FROM record WHERE stream_id = $(streamId)`; const params = { streamId, limit: limit + 1 }; if (after !== undefined) { query += ` AND index > $(after)`; params.after = after; } query += ` ORDER BY index ASC LIMIT $(limit)`; const records = await db.manyOrNone(query, params); const countResult = await db.one(`SELECT COUNT(*) as count FROM record WHERE stream_id = $(streamId)`, { streamId }); const total = parseInt(countResult.count); const hasMore = records.length > limit; if (hasMore) { records.pop(); // Remove the extra record } return { success: true, data: { records: records.map(mapRecordFromDb), total, hasMore, }, }; } catch (error) { logger.error("Failed to list records", { error, streamId }); return { success: false, error: { code: "DATABASE_ERROR", message: "Failed to list records", }, }; } } /** * Convert record to API response format */ export function recordToResponse(record) { let content = record.content; // Parse JSON content if needed if (record.content_type === "application/json" && typeof content === "string") { try { content = JSON.parse(content); } catch { // Keep as string if parse fails } } return { index: record.index, content: content, content_type: record.content_type, name: record.name, hash: record.hash, previous_hash: record.previous_hash, author: record.author_id, timestamp: record.created_at.toISOString(), }; } //# sourceMappingURL=records.js.map