database.lua
1 --[[ 2 ToasterGen Spin 3 4 Copyright (C) 2025 Clifton Toaster Reid <cliftontreid@duck.com> 5 6 This library is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Lesser General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 ]] 11 12 ---@class Entry 13 --- Represents a database entry with metadata 14 ---@field id number Unique numeric identifier of the entry 15 ---@field data any User-provided data payload (must be JSON-serializable) 16 ---@field createdAt number UNIX timestamp of creation 17 ---@field updatedAt number UNIX timestamp of last modification 18 19 ---@class Database 20 local Database = {} 21 local Tracer = require("src.trace") 22 23 --- Initializes and ensures the existence of the database directory structure. 24 ---@param directory? string Custom root directory path (default: "/.db/") 25 ---@return string Path to initialized directory 26 function Database.init(directory) 27 local tr = Tracer.new() 28 tr:setName("database.init") 29 tr:addTag("directory", directory or "/.db/") 30 31 directory = directory or "/.db/" 32 if not fs.exists(directory) then 33 tr:addAnnotation("Directory does not exist, creating.") 34 fs.makeDir(directory) 35 else 36 tr:addAnnotation("Directory already exists.") 37 end 38 39 Tracer.addSpan(tr:endSpan()) 40 return directory 41 end 42 43 --- Creates a new database entry in the specified collection. 44 ---@param collection string Name of the collection (subdirectory) 45 ---@param id number|string Numeric identifier for the record (auto-converted to number) 46 ---@param data any Data payload to store (must be JSON-serializable) 47 ---@return boolean success Operation status 48 ---@return string? error_message Description of failure 49 function Database.create(collection, id, data) 50 local tr = Tracer.new() 51 tr:setName("database.create") 52 tr:addTag("collection", collection) 53 tr:addTag("id", tostring(id)) 54 55 local directory = Database.init(nil) .. collection .. "/" 56 if not fs.exists(directory) then 57 tr:addAnnotation("Collection directory does not exist, creating.") 58 fs.makeDir(directory) 59 end 60 61 local path = directory .. id .. ".json" 62 tr:addTag("path", path) 63 64 local file, err = fs.open(path, "w") 65 if not file then 66 tr:addAnnotation("Failed to open file for writing: " .. (err or "unknown error")) 67 Tracer.addSpan(tr:endSpan()) 68 return false, "Failed to open file for writing" 69 end 70 71 -- Create Entry with metadata 72 local currentTime = os.time() 73 local entry = { 74 id = tonumber(id), 75 data = data, 76 createdAt = currentTime, 77 updatedAt = currentTime, 78 } 79 80 local success, writeErr = pcall(function() file.write(textutils.serializeJSON(entry)) end) 81 file.close() 82 83 if not success then 84 tr:addAnnotation("Failed to write JSON data: " .. (writeErr or "unknown error")) 85 Tracer.addSpan(tr:endSpan()) 86 pcall(function() fs.delete(path) end) 87 return false, "Failed to write data" 88 end 89 90 tr:addAnnotation("Entry created successfully.") 91 Tracer.addSpan(tr:endSpan()) 92 return true 93 end 94 95 --- Retrieves an entry from the specified collection. 96 ---@param collection string Name of the collection (subdirectory) 97 ---@param id number|string Numeric identifier of the record 98 ---@return Entry? entry Deserialized entry object if found 99 ---@return string? error_message Description of failure 100 function Database.read(collection, id) 101 local tr = Tracer.new() 102 tr:setName("database.read") 103 tr:addTag("collection", collection) 104 tr:addTag("id", tostring(id)) 105 106 local directory = Database.init(nil) .. collection .. "/" 107 if not fs.exists(directory) then 108 tr:addAnnotation("Collection does not exist.") 109 Tracer.addSpan(tr:endSpan()) 110 return nil, "Collection does not exist" 111 end 112 113 local path = directory .. id .. ".json" 114 tr:addTag("path", path) 115 if not fs.exists(path) then 116 tr:addAnnotation("Record does not exist.") 117 Tracer.addSpan(tr:endSpan()) 118 return nil, "Record does not exist" 119 end 120 121 local file, err = fs.open(path, "r") 122 if not file then 123 tr:addAnnotation("Failed to open file for reading: " .. (err or "unknown error")) 124 Tracer.addSpan(tr:endSpan()) 125 return nil, "Failed to open file for reading" 126 end 127 128 local content = file.readAll() 129 file.close() 130 131 local success, entry = pcall(function() return textutils.unserializeJSON(content) end) 132 133 if not success then 134 tr:addAnnotation("Failed to unserialize JSON content.") 135 Tracer.addSpan(tr:endSpan()) 136 return nil, "Failed to parse record data" 137 end 138 139 tr:addAnnotation("Entry read successfully.") 140 Tracer.addSpan(tr:endSpan()) 141 return entry 142 end 143 144 --- Updates an existing entry's data while preserving metadata. 145 ---@param collection string Name of the collection (subdirectory) 146 ---@param id number|string Numeric identifier of the record 147 ---@param data any New data payload to store (must be JSON-serializable) 148 ---@return boolean success Operation status 149 ---@return string? error_message Description of failure 150 function Database.update(collection, id, data) 151 local tr = Tracer.new() 152 tr:setName("database.update") 153 tr:addTag("collection", collection) 154 tr:addTag("id", tostring(id)) 155 156 local directory = Database.init(nil) .. collection .. "/" 157 if not fs.exists(directory) then 158 tr:addAnnotation("Collection does not exist.") 159 Tracer.addSpan(tr:endSpan()) 160 return false, "Collection does not exist" 161 end 162 163 local path = directory .. id .. ".json" 164 tr:addTag("path", path) 165 if not fs.exists(path) then 166 tr:addAnnotation("Record does not exist.") 167 Tracer.addSpan(tr:endSpan()) 168 return false, "Record does not exist" 169 end 170 171 local existingEntry, readErr = Database.read(collection, id) 172 if not existingEntry then 173 tr:addAnnotation("Failed to read existing entry for update: " .. (readErr or "unknown error")) 174 Tracer.addSpan(tr:endSpan()) 175 return false, "Failed to read existing record for update" 176 end 177 178 existingEntry.data = data 179 existingEntry.updatedAt = os.time() 180 181 local file, openErr = fs.open(path, "w") 182 if not file then 183 tr:addAnnotation("Failed to open file for writing update: " .. (openErr or "unknown error")) 184 Tracer.addSpan(tr:endSpan()) 185 return false, "Failed to open file for writing" 186 end 187 188 local success, writeErr = pcall(function() file.write(textutils.serializeJSON(existingEntry)) end) 189 file.close() 190 191 if not success then 192 tr:addAnnotation("Failed to write updated JSON data: " .. (writeErr or "unknown error")) 193 Tracer.addSpan(tr:endSpan()) 194 return false, "Failed to write updated data" 195 end 196 197 tr:addAnnotation("Entry updated successfully.") 198 Tracer.addSpan(tr:endSpan()) 199 return true 200 end 201 202 --- Permanently deletes an entry from the database. 203 ---@param collection string Name of the collection (subdirectory) 204 ---@param id number|string Numeric identifier of the record 205 ---@return boolean success Operation status (true even if file didn't exist) 206 ---@return string? error_message Description of failure 207 function Database.delete(collection, id) 208 local tr = Tracer.new() 209 tr:setName("database.delete") 210 tr:addTag("collection", collection) 211 tr:addTag("id", tostring(id)) 212 213 local directory = Database.init(nil) .. collection .. "/" 214 if not fs.exists(directory) then 215 tr:addAnnotation("Collection does not exist.") 216 Tracer.addSpan(tr:endSpan()) 217 return false, "Collection does not exist" 218 end 219 220 local path = directory .. id .. ".json" 221 tr:addTag("path", path) 222 if not fs.exists(path) then 223 tr:addAnnotation("Record does not exist, nothing to delete.") 224 Tracer.addSpan(tr:endSpan()) 225 return false, "Record does not exist" 226 end 227 228 local success, err = pcall(function() return fs.delete(path) end) 229 230 if not success then 231 tr:addAnnotation("Failed to delete file: " .. (err or "unknown error")) 232 Tracer.addSpan(tr:endSpan()) 233 return false, "Failed to delete record" 234 end 235 236 tr:addAnnotation("Entry deleted successfully.") 237 Tracer.addSpan(tr:endSpan()) 238 return true 239 end 240 241 --- Lists all record IDs in a collection. 242 ---@param collection string Name of the collection (subdirectory) 243 ---@return string[] Array of record IDs as strings 244 function Database.list(collection) 245 local tr = Tracer.new() 246 tr:setName("database.list") 247 tr:addTag("collection", collection) 248 249 local directory = Database.init(nil) .. collection .. "/" 250 if not fs.exists(directory) then 251 tr:addAnnotation("Collection does not exist.") 252 Tracer.addSpan(tr:endSpan()) 253 return {} 254 end 255 256 local files = fs.list(directory) 257 local records = {} 258 259 for _, file in ipairs(files) do 260 if string.match(file, "%.json$") then 261 local id = string.sub(file, 1, -6) 262 table.insert(records, id) 263 end 264 end 265 266 tr:addAnnotation(string.format("Found %d records.", #records)) 267 Tracer.addSpan(tr:endSpan()) 268 return records 269 end 270 271 --- Checks if an entry exists in the specified collection. 272 ---@param collection string Name of the collection (subdirectory) 273 ---@param id number|string Numeric identifier of the record 274 ---@return boolean exists True if the entry exists, false otherwise 275 function Database.exists(collection, id) 276 local tr = Tracer.new() 277 tr:setName("database.exists") 278 tr:addTag("collection", collection) 279 tr:addTag("id", tostring(id)) 280 281 local directory = Database.init(nil) .. collection .. "/" 282 if not fs.exists(directory) then 283 tr:addAnnotation("Collection does not exist.") 284 Tracer.addSpan(tr:endSpan()) 285 return false 286 end 287 288 local path = directory .. id .. ".json" 289 tr:addTag("path", path) 290 local exists = fs.exists(path) 291 tr:addAnnotation(exists and "Record exists." or "Record does not exist.") 292 Tracer.addSpan(tr:endSpan()) 293 return exists 294 end 295 296 --- Retrieves nested values from objects using dot-notation paths 297 ---@param obj table Root table to search 298 ---@param path string Dot-separated path (e.g., "user.profile.age") 299 ---@return any|nil value Found value or nil if path invalid 300 local function getNestedValue(obj, path) 301 local current = obj 302 for segment in string.gmatch(path, "[^%.]+") do 303 if type(current) ~= "table" or current[segment] == nil then 304 return nil 305 end 306 current = current[segment] 307 end 308 return current 309 end 310 311 --- Searches for entries matching query criteria in the collection. 312 ---@param collection string Name of the collection (subdirectory) 313 ---@param query table Key-value pairs to match against entry.data. 314 --- Supports dot notation for nested fields (e.g., {["user.name"] = "John"}) 315 ---@return Entry[] results Array of matching Entry objects 316 ---@return string? error_message Description of failure 317 function Database.search(collection, query) 318 local tr = Tracer.new() 319 tr:setName("database.search") 320 tr:addTag("collection", collection) 321 322 local directory = Database.init(nil) .. collection .. "/" 323 if not fs.exists(directory) then 324 tr:addAnnotation("Collection does not exist.") 325 Tracer.addSpan(tr:endSpan()) 326 return {}, "Collection does not exist" 327 end 328 329 local files = fs.list(directory) 330 local results = {} 331 local filesChecked = 0 332 local filesMatched = 0 333 334 for _, file in ipairs(files) do 335 if string.match(file, "%.json$") then 336 filesChecked = filesChecked + 1 337 local path = directory .. file 338 local fileHandle, openErr = fs.open(path, "r") 339 if fileHandle then 340 local content = fileHandle.readAll() 341 fileHandle.close() 342 343 local success, entry = pcall(function() return textutils.unserializeJSON(content) end) 344 if success and entry and type(entry.data) == "table" then 345 local match = true 346 for key, value in pairs(query) do 347 if getNestedValue(entry.data, key) ~= value then 348 match = false 349 break 350 end 351 end 352 353 if match then 354 filesMatched = filesMatched + 1 355 table.insert(results, entry) 356 end 357 elseif not success then 358 tr:addAnnotation(string.format("Failed to parse file %s during search.", file)) 359 end 360 else 361 tr:addAnnotation(string.format("Failed to open file %s for reading during search: %s", file, 362 openErr or "unknown")) 363 end 364 end 365 end 366 367 tr:addAnnotation(string.format("Search complete. Checked %d files, found %d matches.", filesChecked, filesMatched)) 368 Tracer.addSpan(tr:endSpan()) 369 return results 370 end 371 372 --- Checks if any entries matching the query criteria exist in the specified collection. 373 ---@param collection string Name of the collection (subdirectory) 374 ---@param query table Key-value pairs to match against entry.data 375 ---@return boolean exists True if at least one matching entry exists 376 ---@return string? error_message Description of failure 377 function Database.existsQuery(collection, query) 378 local tr = Tracer.new() 379 tr:setName("database.existsQuery") 380 tr:addTag("collection", collection) 381 382 local directory = Database.init(nil) .. collection .. "/" 383 if not fs.exists(directory) then 384 tr:addAnnotation("Collection does not exist.") 385 Tracer.addSpan(tr:endSpan()) 386 return false, "Collection does not exist" 387 end 388 389 local files = fs.list(directory) 390 local foundMatch = false 391 392 for _, file in ipairs(files) do 393 if string.match(file, "%.json$") then 394 tr:addAnnotation(string.format("Checking file: %s", file)) 395 local path = directory .. file 396 local fileHandle, openErr = fs.open(path, "r") 397 if fileHandle then 398 tr:addAnnotation(string.format("Successfully opened %s for reading.", file)) 399 local content = fileHandle.readAll() 400 fileHandle.close() 401 402 local success, entry = pcall(function() return textutils.unserializeJSON(content) end) 403 if success and entry and type(entry.data) == "table" then 404 tr:addAnnotation(string.format("Successfully parsed %s.", file)) 405 local match = true 406 for key, value in pairs(query) do 407 tr:addAnnotation(string.format("Checking query key '%s' against data in %s.", key, file)) 408 if getNestedValue(entry.data, key) ~= value then 409 tr:addAnnotation(string.format("Query mismatch for key '%s' in %s.", key, file)) 410 match = false 411 break 412 end 413 end 414 415 if match then 416 tr:addAnnotation(string.format("Found matching entry in file %s. Stopping search.", file)) 417 foundMatch = true 418 break -- Exit the loop as soon as a match is found 419 end 420 elseif not success then 421 tr:addAnnotation(string.format("Failed to parse file %s during existsQuery.", file)) 422 else 423 tr:addAnnotation(string.format("Parsed file %s, but entry.data is not a table.", file)) 424 end 425 else 426 tr:addAnnotation(string.format("Failed to open file %s for reading during existsQuery: %s", file, 427 openErr or "unknown")) 428 end 429 else 430 tr:addAnnotation(string.format("Skipping non-JSON file: %s", file)) 431 end 432 end 433 434 tr:addAnnotation(foundMatch and "Matching entry found." or "No matching entry found.") 435 Tracer.addSpan(tr:endSpan()) 436 return foundMatch 437 end 438 439 return Database