A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): add comprehensive RecordManager with CRUD, batch ops

Introduce a full-featured RecordManager for AT Protocol repositories.

Adds type-safe CRUD operations for records, including create, read, list,
update (upsert), and delete, with support for pagination, compare-and-swap,
and atomic batch writes via applyWrites.

Includes utility helpers for TID generation and AT URI parsing, plus
serialisable request/response models.

Also adds a comprehensive examples module demonstrating real-world usage
patterns (stats syncing, achievements, profiles, safe updates, pagination,
and atomic transactions) to serve as reference documentation.

Ewan fcd23a69 f9ea5e32

+1199
+625
src/main/kotlin/com/jollywhoppers/atproto/RecordManager.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import com.jollywhoppers.atproto.security.SecurityUtils 4 + import kotlinx.serialization.Serializable 5 + import kotlinx.serialization.json.* 6 + import org.slf4j.LoggerFactory 7 + import java.time.Instant 8 + import java.util.* 9 + 10 + /** 11 + * Comprehensive record management for AT Protocol repositories. 12 + * Provides type-safe CRUD operations for Minecraft data records. 13 + * 14 + * Supported operations: 15 + * - Create: com.atproto.repo.createRecord (generates TID automatically) 16 + * - Read: com.atproto.repo.getRecord (single record) 17 + * - List: com.atproto.repo.listRecords (paginated collection listing) 18 + * - Update: com.atproto.repo.putRecord (update or create with specific rkey) 19 + * - Delete: com.atproto.repo.deleteRecord 20 + * 21 + * All operations require an authenticated session. 22 + */ 23 + class RecordManager( 24 + private val sessionManager: AtProtoSessionManager 25 + ) { 26 + private val logger = LoggerFactory.getLogger("atproto-connect:RecordManager") 27 + private val json = Json { 28 + prettyPrint = false 29 + ignoreUnknownKeys = true 30 + } 31 + 32 + // ============================================================================ 33 + // CREATE OPERATIONS 34 + // ============================================================================ 35 + 36 + /** 37 + * Creates a new record in the repository with an auto-generated TID. 38 + * Use this for records that should have unique timestamps (stats, sessions, achievements). 39 + * 40 + * @param playerUuid The player's UUID 41 + * @param collection The lexicon collection name (e.g., "com.jollywhoppers.minecraft.player.stats") 42 + * @param record The record data (must include `$type` field) 43 + * @param validate Whether to validate the record against the lexicon schema (default: true) 44 + * @return StrongRef containing the URI and CID of the created record 45 + */ 46 + suspend fun createRecord( 47 + playerUuid: UUID, 48 + collection: String, 49 + record: JsonElement, 50 + validate: Boolean = true 51 + ): Result<StrongRef> = runCatching { 52 + logger.info("Creating record in collection: $collection for player: ${SecurityUtils.sanitizeForLog(playerUuid.toString())}") 53 + 54 + val session = sessionManager.getSession(playerUuid).getOrThrow() 55 + 56 + val request = CreateRecordRequest( 57 + repo = session.did, 58 + collection = collection, 59 + record = record, 60 + validate = validate 61 + ) 62 + 63 + val responseBody = sessionManager.makeAuthenticatedRequest( 64 + uuid = playerUuid, 65 + method = "POST", 66 + endpoint = "com.atproto.repo.createRecord", 67 + body = json.encodeToString(CreateRecordRequest.serializer(), request) 68 + ).getOrThrow() 69 + 70 + val response = json.decodeFromString<CreateRecordResponse>(responseBody) 71 + logger.info("Record created successfully: ${response.uri}") 72 + 73 + StrongRef(uri = response.uri, cid = response.cid) 74 + } 75 + 76 + /** 77 + * Creates a typed record with automatic serialization. 78 + * Convenience method that handles JSON encoding. 79 + */ 80 + inline fun <reified T : @Serializable Any> createTypedRecord( 81 + playerUuid: UUID, 82 + collection: String, 83 + record: T, 84 + validate: Boolean = true 85 + ): Result<StrongRef> = runCatching { 86 + val jsonElement = json.encodeToJsonElement(record) 87 + createRecord(playerUuid, collection, jsonElement, validate).getOrThrow() 88 + } 89 + 90 + // ============================================================================ 91 + // READ OPERATIONS 92 + // ============================================================================ 93 + 94 + /** 95 + * Retrieves a single record from the repository. 96 + * 97 + * @param playerUuid The player's UUID 98 + * @param collection The lexicon collection name 99 + * @param rkey The record key (TID or literal like "self") 100 + * @param cid Optional specific version CID 101 + * @return RecordData containing the URI, value, and CID 102 + */ 103 + suspend fun getRecord( 104 + playerUuid: UUID, 105 + collection: String, 106 + rkey: String, 107 + cid: String? = null 108 + ): Result<RecordData> = runCatching { 109 + logger.info("Fetching record: $collection/$rkey") 110 + 111 + val session = sessionManager.getSession(playerUuid).getOrThrow() 112 + 113 + val params = buildString { 114 + append("repo=${session.did}") 115 + append("&collection=$collection") 116 + append("&rkey=$rkey") 117 + cid?.let { append("&cid=$it") } 118 + } 119 + 120 + val responseBody = sessionManager.makeAuthenticatedRequest( 121 + uuid = playerUuid, 122 + method = "GET", 123 + endpoint = "com.atproto.repo.getRecord?$params", 124 + body = null 125 + ).getOrThrow() 126 + 127 + val response = json.decodeFromString<GetRecordResponse>(responseBody) 128 + logger.info("Record retrieved successfully") 129 + 130 + RecordData( 131 + uri = response.uri, 132 + value = response.value, 133 + cid = response.cid 134 + ) 135 + } 136 + 137 + /** 138 + * Retrieves a typed record with automatic deserialization. 139 + */ 140 + suspend inline fun <reified T : @Serializable Any> getTypedRecord( 141 + playerUuid: UUID, 142 + collection: String, 143 + rkey: String, 144 + cid: String? = null 145 + ): Result<TypedRecordData<T>> = runCatching { 146 + val recordData = getRecord(playerUuid, collection, rkey, cid).getOrThrow() 147 + TypedRecordData( 148 + uri = recordData.uri, 149 + value = json.decodeFromJsonElement(recordData.value), 150 + cid = recordData.cid 151 + ) 152 + } 153 + 154 + /** 155 + * Lists records in a collection with pagination support. 156 + * 157 + * @param playerUuid The player's UUID 158 + * @param collection The lexicon collection name 159 + * @param limit Maximum records to return (1-100, default 50) 160 + * @param cursor Pagination cursor from previous response 161 + * @param reverse List in reverse chronological order 162 + * @return RecordList containing records and optional cursor for next page 163 + */ 164 + suspend fun listRecords( 165 + playerUuid: UUID, 166 + collection: String, 167 + limit: Int = 50, 168 + cursor: String? = null, 169 + reverse: Boolean = false 170 + ): Result<RecordList> = runCatching { 171 + require(limit in 1..100) { "Limit must be between 1 and 100" } 172 + 173 + logger.info("Listing records from collection: $collection") 174 + 175 + val session = sessionManager.getSession(playerUuid).getOrThrow() 176 + 177 + val params = buildString { 178 + append("repo=${session.did}") 179 + append("&collection=$collection") 180 + append("&limit=$limit") 181 + cursor?.let { append("&cursor=$it") } 182 + if (reverse) append("&reverse=true") 183 + } 184 + 185 + val responseBody = sessionManager.makeAuthenticatedRequest( 186 + uuid = playerUuid, 187 + method = "GET", 188 + endpoint = "com.atproto.repo.listRecords?$params", 189 + body = null 190 + ).getOrThrow() 191 + 192 + val response = json.decodeFromString<ListRecordsResponse>(responseBody) 193 + logger.info("Retrieved ${response.records.size} records") 194 + 195 + RecordList( 196 + records = response.records.map { 197 + RecordData(uri = it.uri, value = it.value, cid = it.cid) 198 + }, 199 + cursor = response.cursor 200 + ) 201 + } 202 + 203 + /** 204 + * Lists all records in a collection, handling pagination automatically. 205 + * WARNING: This will fetch ALL records, which could be many requests for large collections. 206 + * 207 + * @param playerUuid The player's UUID 208 + * @param collection The lexicon collection name 209 + * @param batchSize Records per request (1-100, default 50) 210 + * @param maxRecords Maximum total records to fetch (default: unlimited) 211 + * @return List of all records 212 + */ 213 + suspend fun listAllRecords( 214 + playerUuid: UUID, 215 + collection: String, 216 + batchSize: Int = 50, 217 + maxRecords: Int? = null 218 + ): Result<List<RecordData>> = runCatching { 219 + val allRecords = mutableListOf<RecordData>() 220 + var cursor: String? = null 221 + var remainingLimit = maxRecords 222 + 223 + do { 224 + val currentLimit = when { 225 + remainingLimit == null -> batchSize 226 + remainingLimit < batchSize -> remainingLimit 227 + else -> batchSize 228 + } 229 + 230 + val result = listRecords( 231 + playerUuid = playerUuid, 232 + collection = collection, 233 + limit = currentLimit, 234 + cursor = cursor 235 + ).getOrThrow() 236 + 237 + allRecords.addAll(result.records) 238 + cursor = result.cursor 239 + 240 + remainingLimit = remainingLimit?.minus(result.records.size) 241 + 242 + if (remainingLimit != null && remainingLimit <= 0) break 243 + } while (cursor != null) 244 + 245 + logger.info("Fetched total of ${allRecords.size} records from $collection") 246 + allRecords 247 + } 248 + 249 + // ============================================================================ 250 + // UPDATE OPERATIONS 251 + // ============================================================================ 252 + 253 + /** 254 + * Updates a record or creates it if it doesn't exist (upsert). 255 + * Use this for singleton records with literal rkeys like "self" (profile). 256 + * 257 + * @param playerUuid The player's UUID 258 + * @param collection The lexicon collection name 259 + * @param rkey The record key 260 + * @param record The new record data (must include `$type` field) 261 + * @param swapRecord Optional CID for compare-and-swap (prevents race conditions) 262 + * @param swapCommit Optional commit CID for compare-and-swap 263 + * @param validate Whether to validate the record against the lexicon schema 264 + * @return StrongRef containing the URI and CID of the updated record 265 + */ 266 + suspend fun putRecord( 267 + playerUuid: UUID, 268 + collection: String, 269 + rkey: String, 270 + record: JsonElement, 271 + swapRecord: String? = null, 272 + swapCommit: String? = null, 273 + validate: Boolean = true 274 + ): Result<StrongRef> = runCatching { 275 + logger.info("Putting record in collection: $collection with rkey: $rkey") 276 + 277 + val session = sessionManager.getSession(playerUuid).getOrThrow() 278 + 279 + val request = PutRecordRequest( 280 + repo = session.did, 281 + collection = collection, 282 + rkey = rkey, 283 + record = record, 284 + swapRecord = swapRecord, 285 + swapCommit = swapCommit, 286 + validate = validate 287 + ) 288 + 289 + val responseBody = sessionManager.makeAuthenticatedRequest( 290 + uuid = playerUuid, 291 + method = "POST", 292 + endpoint = "com.atproto.repo.putRecord", 293 + body = json.encodeToString(PutRecordRequest.serializer(), request) 294 + ).getOrThrow() 295 + 296 + val response = json.decodeFromString<PutRecordResponse>(responseBody) 297 + logger.info("Record updated successfully: ${response.uri}") 298 + 299 + StrongRef(uri = response.uri, cid = response.cid) 300 + } 301 + 302 + /** 303 + * Updates a typed record with automatic serialization. 304 + */ 305 + suspend inline fun <reified T : @Serializable Any> putTypedRecord( 306 + playerUuid: UUID, 307 + collection: String, 308 + rkey: String, 309 + record: T, 310 + swapRecord: String? = null, 311 + swapCommit: String? = null, 312 + validate: Boolean = true 313 + ): Result<StrongRef> = runCatching { 314 + val jsonElement = json.encodeToJsonElement(record) 315 + putRecord(playerUuid, collection, rkey, jsonElement, swapRecord, swapCommit, validate).getOrThrow() 316 + } 317 + 318 + // ============================================================================ 319 + // DELETE OPERATIONS 320 + // ============================================================================ 321 + 322 + /** 323 + * Deletes a record from the repository. 324 + * 325 + * @param playerUuid The player's UUID 326 + * @param collection The lexicon collection name 327 + * @param rkey The record key to delete 328 + * @param swapRecord Optional CID for compare-and-swap (prevents accidental deletion) 329 + * @param swapCommit Optional commit CID for compare-and-swap 330 + */ 331 + suspend fun deleteRecord( 332 + playerUuid: UUID, 333 + collection: String, 334 + rkey: String, 335 + swapRecord: String? = null, 336 + swapCommit: String? = null 337 + ): Result<DeleteRecordResponse> = runCatching { 338 + logger.info("Deleting record: $collection/$rkey") 339 + 340 + val session = sessionManager.getSession(playerUuid).getOrThrow() 341 + 342 + val request = DeleteRecordRequest( 343 + repo = session.did, 344 + collection = collection, 345 + rkey = rkey, 346 + swapRecord = swapRecord, 347 + swapCommit = swapCommit 348 + ) 349 + 350 + val responseBody = sessionManager.makeAuthenticatedRequest( 351 + uuid = playerUuid, 352 + method = "POST", 353 + endpoint = "com.atproto.repo.deleteRecord", 354 + body = json.encodeToString(DeleteRecordRequest.serializer(), request) 355 + ).getOrThrow() 356 + 357 + logger.info("Record deleted successfully") 358 + 359 + // Delete endpoint returns empty object or commit info 360 + json.decodeFromString<DeleteRecordResponse>(responseBody) 361 + } 362 + 363 + // ============================================================================ 364 + // BATCH OPERATIONS 365 + // ============================================================================ 366 + 367 + /** 368 + * Applies multiple writes (create, update, delete) in a single atomic transaction. 369 + * All operations succeed or all fail together. 370 + * 371 + * @param playerUuid The player's UUID 372 + * @param writes List of write operations to perform 373 + * @param validate Whether to validate records 374 + * @param swapCommit Optional commit CID for compare-and-swap 375 + * @return Commit information for the transaction 376 + */ 377 + suspend fun applyWrites( 378 + playerUuid: UUID, 379 + writes: List<WriteOperation>, 380 + validate: Boolean = true, 381 + swapCommit: String? = null 382 + ): Result<ApplyWritesResponse> = runCatching { 383 + logger.info("Applying ${writes.size} write operations") 384 + 385 + val session = sessionManager.getSession(playerUuid).getOrThrow() 386 + 387 + val request = ApplyWritesRequest( 388 + repo = session.did, 389 + writes = writes.map { it.toJson() }, 390 + validate = validate, 391 + swapCommit = swapCommit 392 + ) 393 + 394 + val responseBody = sessionManager.makeAuthenticatedRequest( 395 + uuid = playerUuid, 396 + method = "POST", 397 + endpoint = "com.atproto.repo.applyWrites", 398 + body = json.encodeToString(ApplyWritesRequest.serializer(), request) 399 + ).getOrThrow() 400 + 401 + val response = json.decodeFromString<ApplyWritesResponse>(responseBody) 402 + logger.info("Writes applied successfully") 403 + response 404 + } 405 + 406 + // ============================================================================ 407 + // UTILITY METHODS 408 + // ============================================================================ 409 + 410 + /** 411 + * Generates a new TID (Timestamp Identifier) for use as a record key. 412 + * TIDs are sortable timestamps with sub-millisecond precision. 413 + */ 414 + fun generateTID(): String { 415 + // TID format: base32-encoded timestamp + clock ID 416 + val timestamp = Instant.now().toEpochMilli() 417 + val clockId = Random().nextInt(1024) 418 + 419 + // Simplified TID generation (real implementation would use proper base32) 420 + // For production, use a proper TID library 421 + return "${timestamp.toString(32)}${clockId.toString(32)}" 422 + } 423 + 424 + /** 425 + * Parses an AT URI into its components. 426 + * Format: at://did/collection/rkey 427 + */ 428 + fun parseAtUri(uri: String): AtUriComponents? { 429 + if (!uri.startsWith("at://")) return null 430 + 431 + val parts = uri.removePrefix("at://").split("/") 432 + if (parts.size != 3) return null 433 + 434 + return AtUriComponents( 435 + did = parts[0], 436 + collection = parts[1], 437 + rkey = parts[2] 438 + ) 439 + } 440 + 441 + // ============================================================================ 442 + // DATA CLASSES 443 + // ============================================================================ 444 + 445 + @Serializable 446 + data class CreateRecordRequest( 447 + val repo: String, 448 + val collection: String, 449 + val record: JsonElement, 450 + val rkey: String? = null, 451 + val validate: Boolean? = null, 452 + val swapCommit: String? = null 453 + ) 454 + 455 + @Serializable 456 + data class CreateRecordResponse( 457 + val uri: String, 458 + val cid: String 459 + ) 460 + 461 + @Serializable 462 + data class GetRecordResponse( 463 + val uri: String, 464 + val cid: String?, 465 + val value: JsonElement 466 + ) 467 + 468 + @Serializable 469 + data class ListRecordsResponse( 470 + val cursor: String? = null, 471 + val records: List<RecordItem> 472 + ) 473 + 474 + @Serializable 475 + data class RecordItem( 476 + val uri: String, 477 + val cid: String, 478 + val value: JsonElement 479 + ) 480 + 481 + @Serializable 482 + data class PutRecordRequest( 483 + val repo: String, 484 + val collection: String, 485 + val rkey: String, 486 + val record: JsonElement, 487 + val validate: Boolean? = null, 488 + val swapRecord: String? = null, 489 + val swapCommit: String? = null 490 + ) 491 + 492 + @Serializable 493 + data class PutRecordResponse( 494 + val uri: String, 495 + val cid: String 496 + ) 497 + 498 + @Serializable 499 + data class DeleteRecordRequest( 500 + val repo: String, 501 + val collection: String, 502 + val rkey: String, 503 + val swapRecord: String? = null, 504 + val swapCommit: String? = null 505 + ) 506 + 507 + @Serializable 508 + data class DeleteRecordResponse( 509 + val commit: JsonObject? = null 510 + ) 511 + 512 + @Serializable 513 + data class ApplyWritesRequest( 514 + val repo: String, 515 + val writes: List<JsonElement>, 516 + val validate: Boolean? = null, 517 + val swapCommit: String? = null 518 + ) 519 + 520 + @Serializable 521 + data class ApplyWritesResponse( 522 + val commit: JsonObject? = null, 523 + val results: List<JsonObject>? = null 524 + ) 525 + 526 + /** 527 + * Reference to a record (URI + CID) 528 + */ 529 + data class StrongRef( 530 + val uri: String, 531 + val cid: String 532 + ) 533 + 534 + /** 535 + * Record data with untyped value 536 + */ 537 + data class RecordData( 538 + val uri: String, 539 + val value: JsonElement, 540 + val cid: String? 541 + ) 542 + 543 + /** 544 + * Record data with typed value 545 + */ 546 + data class TypedRecordData<T>( 547 + val uri: String, 548 + val value: T, 549 + val cid: String? 550 + ) 551 + 552 + /** 553 + * Paginated list of records 554 + */ 555 + data class RecordList( 556 + val records: List<RecordData>, 557 + val cursor: String? 558 + ) 559 + 560 + /** 561 + * Components of an AT URI 562 + */ 563 + data class AtUriComponents( 564 + val did: String, 565 + val collection: String, 566 + val rkey: String 567 + ) 568 + 569 + /** 570 + * Base class for batch write operations 571 + */ 572 + sealed class WriteOperation { 573 + abstract fun toJson(): JsonElement 574 + 575 + @Serializable 576 + data class Create( 577 + val collection: String, 578 + val rkey: String? = null, 579 + val value: JsonElement 580 + ) : WriteOperation() { 581 + override fun toJson(): JsonElement = json.encodeToJsonElement( 582 + JsonObject(mapOf( 583 + "\$type" to JsonPrimitive("com.atproto.repo.applyWrites#create"), 584 + "collection" to JsonPrimitive(collection), 585 + "rkey" to (rkey?.let { JsonPrimitive(it) } ?: JsonNull), 586 + "value" to value 587 + )) 588 + ) 589 + } 590 + 591 + @Serializable 592 + data class Update( 593 + val collection: String, 594 + val rkey: String, 595 + val value: JsonElement 596 + ) : WriteOperation() { 597 + override fun toJson(): JsonElement = json.encodeToJsonElement( 598 + JsonObject(mapOf( 599 + "\$type" to JsonPrimitive("com.atproto.repo.applyWrites#update"), 600 + "collection" to JsonPrimitive(collection), 601 + "rkey" to JsonPrimitive(rkey), 602 + "value" to value 603 + )) 604 + ) 605 + } 606 + 607 + @Serializable 608 + data class Delete( 609 + val collection: String, 610 + val rkey: String 611 + ) : WriteOperation() { 612 + override fun toJson(): JsonElement = json.encodeToJsonElement( 613 + JsonObject(mapOf( 614 + "\$type" to JsonPrimitive("com.atproto.repo.applyWrites#delete"), 615 + "collection" to JsonPrimitive(collection), 616 + "rkey" to JsonPrimitive(rkey) 617 + )) 618 + ) 619 + } 620 + 621 + companion object { 622 + private val json = Json { prettyPrint = false } 623 + } 624 + } 625 + }
+574
src/main/kotlin/com/jollywhoppers/atproto/examples/RecordManagerExamples.kt
··· 1 + package com.jollywhoppers.atproto.examples 2 + 3 + import com.jollywhoppers.atproto.RecordManager 4 + import com.jollywhoppers.atproto.AtProtoSessionManager 5 + import kotlinx.serialization.Serializable 6 + import org.slf4j.LoggerFactory 7 + import java.util.* 8 + 9 + /** 10 + * Comprehensive examples demonstrating the RecordManager API. 11 + * Shows all CRUD operations and best practices. 12 + */ 13 + class RecordManagerExamples( 14 + private val sessionManager: AtProtoSessionManager 15 + ) { 16 + private val logger = LoggerFactory.getLogger("atproto-connect:Examples") 17 + private val recordManager = RecordManager(sessionManager) 18 + 19 + // ============================================================================ 20 + // EXAMPLE 1: Creating Records with Auto-Generated TIDs 21 + // ============================================================================ 22 + 23 + /** 24 + * Example: Create a player stats snapshot 25 + * Uses createRecord for automatic TID generation (timestamp-based key) 26 + */ 27 + suspend fun createPlayerStats(playerUuid: UUID): Result<String> = runCatching { 28 + val stats = PlayerStats( 29 + `$type` = "com.jollywhoppers.minecraft.player.stats", 30 + player = PlayerRef( 31 + uuid = playerUuid.toString(), 32 + username = "ExamplePlayer" 33 + ), 34 + statistics = listOf( 35 + Statistic("minecraft:killed.minecraft.zombie", 42), 36 + Statistic("minecraft:mined.minecraft.diamond_ore", 15) 37 + ), 38 + playtimeMinutes = 180, 39 + level = 25, 40 + gamemode = "survival", 41 + syncedAt = java.time.Instant.now().toString() 42 + ) 43 + 44 + val ref = recordManager.createTypedRecord( 45 + playerUuid = playerUuid, 46 + collection = "com.jollywhoppers.minecraft.player.stats", 47 + record = stats, 48 + validate = true 49 + ).getOrThrow() 50 + 51 + logger.info("Created stats record: ${ref.uri}") 52 + ref.uri 53 + } 54 + 55 + /** 56 + * Example: Create an achievement record 57 + */ 58 + suspend fun createAchievement( 59 + playerUuid: UUID, 60 + achievementId: String, 61 + achievementName: String 62 + ): Result<String> = runCatching { 63 + val achievement = Achievement( 64 + `$type` = "com.jollywhoppers.minecraft.achievement", 65 + player = PlayerRef(playerUuid.toString(), "ExamplePlayer"), 66 + achievementId = achievementId, 67 + achievementName = achievementName, 68 + achievedAt = java.time.Instant.now().toString(), 69 + category = "adventure" 70 + ) 71 + 72 + val ref = recordManager.createTypedRecord( 73 + playerUuid = playerUuid, 74 + collection = "com.jollywhoppers.minecraft.achievement", 75 + record = achievement 76 + ).getOrThrow() 77 + 78 + logger.info("Achievement unlocked: ${ref.uri}") 79 + ref.uri 80 + } 81 + 82 + // ============================================================================ 83 + // EXAMPLE 2: Creating/Updating Singleton Records (Profiles) 84 + // ============================================================================ 85 + 86 + /** 87 + * Example: Create or update player profile (singleton with rkey "self") 88 + * Uses putRecord for upsert behavior 89 + */ 90 + suspend fun updatePlayerProfile( 91 + playerUuid: UUID, 92 + displayName: String?, 93 + bio: String? 94 + ): Result<String> = runCatching { 95 + val profile = PlayerProfile( 96 + `$type` = "com.jollywhoppers.minecraft.player.profile", 97 + player = PlayerRef(playerUuid.toString(), "ExamplePlayer"), 98 + displayName = displayName, 99 + bio = bio, 100 + publicStats = true, 101 + publicSessions = true, 102 + updatedAt = java.time.Instant.now().toString() 103 + ) 104 + 105 + val ref = recordManager.putTypedRecord( 106 + playerUuid = playerUuid, 107 + collection = "com.jollywhoppers.minecraft.player.profile", 108 + rkey = "self", // Singleton record uses literal "self" 109 + record = profile 110 + ).getOrThrow() 111 + 112 + logger.info("Profile updated: ${ref.uri}") 113 + ref.uri 114 + } 115 + 116 + // ============================================================================ 117 + // EXAMPLE 3: Reading Records 118 + // ============================================================================ 119 + 120 + /** 121 + * Example: Get a specific record by URI 122 + */ 123 + suspend fun getPlayerProfile(playerUuid: UUID): Result<PlayerProfile> = runCatching { 124 + val data = recordManager.getTypedRecord<PlayerProfile>( 125 + playerUuid = playerUuid, 126 + collection = "com.jollywhoppers.minecraft.player.profile", 127 + rkey = "self" 128 + ).getOrThrow() 129 + 130 + logger.info("Retrieved profile for ${data.value.player.username}") 131 + data.value 132 + } 133 + 134 + /** 135 + * Example: Get a specific stats record by its TID 136 + */ 137 + suspend fun getStatsRecord(playerUuid: UUID, tid: String): Result<PlayerStats> = runCatching { 138 + val data = recordManager.getTypedRecord<PlayerStats>( 139 + playerUuid = playerUuid, 140 + collection = "com.jollywhoppers.minecraft.player.stats", 141 + rkey = tid 142 + ).getOrThrow() 143 + 144 + logger.info("Retrieved stats: Level ${data.value.level}, ${data.value.playtimeMinutes}min playtime") 145 + data.value 146 + } 147 + 148 + // ============================================================================ 149 + // EXAMPLE 4: Listing Records with Pagination 150 + // ============================================================================ 151 + 152 + /** 153 + * Example: List recent achievements (paginated) 154 + */ 155 + suspend fun listRecentAchievements( 156 + playerUuid: UUID, 157 + limit: Int = 10 158 + ): Result<List<Achievement>> = runCatching { 159 + val result = recordManager.listRecords( 160 + playerUuid = playerUuid, 161 + collection = "com.jollywhoppers.minecraft.achievement", 162 + limit = limit, 163 + reverse = true // Most recent first 164 + ).getOrThrow() 165 + 166 + val achievements = result.records.mapNotNull { recordData -> 167 + try { 168 + kotlinx.serialization.json.Json.decodeFromJsonElement<Achievement>(recordData.value) 169 + } catch (e: Exception) { 170 + logger.warn("Failed to parse achievement record", e) 171 + null 172 + } 173 + } 174 + 175 + logger.info("Found ${achievements.size} recent achievements") 176 + achievements 177 + } 178 + 179 + /** 180 + * Example: List all stats with automatic pagination handling 181 + */ 182 + suspend fun getAllPlayerStats(playerUuid: UUID): Result<List<PlayerStats>> = runCatching { 183 + val records = recordManager.listAllRecords( 184 + playerUuid = playerUuid, 185 + collection = "com.jollywhoppers.minecraft.player.stats", 186 + batchSize = 50, 187 + maxRecords = 200 // Optional limit to prevent too many requests 188 + ).getOrThrow() 189 + 190 + val stats = records.mapNotNull { recordData -> 191 + try { 192 + kotlinx.serialization.json.Json.decodeFromJsonElement<PlayerStats>(recordData.value) 193 + } catch (e: Exception) { 194 + logger.warn("Failed to parse stats record", e) 195 + null 196 + } 197 + } 198 + 199 + logger.info("Retrieved ${stats.size} stats records") 200 + stats 201 + } 202 + 203 + /** 204 + * Example: Manual pagination with cursor 205 + */ 206 + suspend fun paginateThroughAchievements(playerUuid: UUID): Result<Unit> = runCatching { 207 + var cursor: String? = null 208 + var page = 1 209 + 210 + do { 211 + val result = recordManager.listRecords( 212 + playerUuid = playerUuid, 213 + collection = "com.jollywhoppers.minecraft.achievement", 214 + limit = 20, 215 + cursor = cursor 216 + ).getOrThrow() 217 + 218 + logger.info("Page $page: ${result.records.size} records") 219 + 220 + // Process this batch 221 + result.records.forEach { recordData -> 222 + // Do something with each record 223 + logger.debug("Processing record: ${recordData.uri}") 224 + } 225 + 226 + cursor = result.cursor 227 + page++ 228 + } while (cursor != null) 229 + 230 + logger.info("Processed $page pages total") 231 + } 232 + 233 + // ============================================================================ 234 + // EXAMPLE 5: Updating Records 235 + // ============================================================================ 236 + 237 + /** 238 + * Example: Update existing profile with race condition protection 239 + */ 240 + suspend fun safelyUpdateProfile( 241 + playerUuid: UUID, 242 + newDisplayName: String 243 + ): Result<String> = runCatching { 244 + // First, get the current record to get its CID 245 + val current = recordManager.getTypedRecord<PlayerProfile>( 246 + playerUuid = playerUuid, 247 + collection = "com.jollywhoppers.minecraft.player.profile", 248 + rkey = "self" 249 + ).getOrThrow() 250 + 251 + // Create updated profile 252 + val updated = current.value.copy( 253 + displayName = newDisplayName, 254 + updatedAt = java.time.Instant.now().toString() 255 + ) 256 + 257 + // Use swapRecord to ensure we're updating the version we read 258 + val ref = recordManager.putTypedRecord( 259 + playerUuid = playerUuid, 260 + collection = "com.jollywhoppers.minecraft.player.profile", 261 + rkey = "self", 262 + record = updated, 263 + swapRecord = current.cid // Prevents race conditions 264 + ).getOrThrow() 265 + 266 + logger.info("Profile safely updated: ${ref.uri}") 267 + ref.uri 268 + } 269 + 270 + // ============================================================================ 271 + // EXAMPLE 6: Deleting Records 272 + // ============================================================================ 273 + 274 + /** 275 + * Example: Delete a specific achievement 276 + */ 277 + suspend fun deleteAchievement( 278 + playerUuid: UUID, 279 + achievementTid: String 280 + ): Result<Unit> = runCatching { 281 + recordManager.deleteRecord( 282 + playerUuid = playerUuid, 283 + collection = "com.jollywhoppers.minecraft.achievement", 284 + rkey = achievementTid 285 + ).getOrThrow() 286 + 287 + logger.info("Achievement deleted: $achievementTid") 288 + } 289 + 290 + /** 291 + * Example: Safe delete with CID verification 292 + */ 293 + suspend fun safelyDeleteRecord( 294 + playerUuid: UUID, 295 + collection: String, 296 + rkey: String 297 + ): Result<Unit> = runCatching { 298 + // Get current record to verify it exists 299 + val current = recordManager.getRecord( 300 + playerUuid = playerUuid, 301 + collection = collection, 302 + rkey = rkey 303 + ).getOrThrow() 304 + 305 + // Delete with CID verification 306 + recordManager.deleteRecord( 307 + playerUuid = playerUuid, 308 + collection = collection, 309 + rkey = rkey, 310 + swapRecord = current.cid // Ensures we delete the exact version we saw 311 + ).getOrThrow() 312 + 313 + logger.info("Record safely deleted: $rkey") 314 + } 315 + 316 + // ============================================================================ 317 + // EXAMPLE 7: Batch Operations (Atomic Transactions) 318 + // ============================================================================ 319 + 320 + /** 321 + * Example: Atomically create multiple achievements 322 + * All succeed or all fail together 323 + */ 324 + suspend fun createMultipleAchievements( 325 + playerUuid: UUID, 326 + achievements: List<Pair<String, String>> // (id, name) pairs 327 + ): Result<Unit> = runCatching { 328 + val writes = achievements.map { (id, name) -> 329 + val achievement = Achievement( 330 + `$type` = "com.jollywhoppers.minecraft.achievement", 331 + player = PlayerRef(playerUuid.toString(), "ExamplePlayer"), 332 + achievementId = id, 333 + achievementName = name, 334 + achievedAt = java.time.Instant.now().toString(), 335 + category = "batch_unlock" 336 + ) 337 + 338 + RecordManager.WriteOperation.Create( 339 + collection = "com.jollywhoppers.minecraft.achievement", 340 + value = kotlinx.serialization.json.Json.encodeToJsonElement(achievement) 341 + ) 342 + } 343 + 344 + recordManager.applyWrites( 345 + playerUuid = playerUuid, 346 + writes = writes 347 + ).getOrThrow() 348 + 349 + logger.info("Created ${achievements.size} achievements atomically") 350 + } 351 + 352 + /** 353 + * Example: Atomic batch update - create stats, update profile 354 + */ 355 + suspend fun syncPlayerDataAtomically( 356 + playerUuid: UUID, 357 + stats: PlayerStats, 358 + profileUpdate: PlayerProfile 359 + ): Result<Unit> = runCatching { 360 + val writes = listOf( 361 + // Create new stats record 362 + RecordManager.WriteOperation.Create( 363 + collection = "com.jollywhoppers.minecraft.player.stats", 364 + value = kotlinx.serialization.json.Json.encodeToJsonElement(stats) 365 + ), 366 + // Update profile 367 + RecordManager.WriteOperation.Update( 368 + collection = "com.jollywhoppers.minecraft.player.profile", 369 + rkey = "self", 370 + value = kotlinx.serialization.json.Json.encodeToJsonElement(profileUpdate) 371 + ) 372 + ) 373 + 374 + recordManager.applyWrites( 375 + playerUuid = playerUuid, 376 + writes = writes 377 + ).getOrThrow() 378 + 379 + logger.info("Player data synced atomically") 380 + } 381 + 382 + // ============================================================================ 383 + // EXAMPLE 8: Real-World Use Case - Stats Syncing 384 + // ============================================================================ 385 + 386 + /** 387 + * Complete example: Sync player statistics on logout 388 + */ 389 + suspend fun onPlayerLogout(playerUuid: UUID, username: String): Result<Unit> = runCatching { 390 + logger.info("Syncing stats for player logout: $username") 391 + 392 + // Gather current statistics 393 + val stats = PlayerStats( 394 + `$type` = "com.jollywhoppers.minecraft.player.stats", 395 + player = PlayerRef(playerUuid.toString(), username), 396 + statistics = gatherMinecraftStats(playerUuid), 397 + playtimeMinutes = calculatePlaytime(playerUuid), 398 + level = getPlayerLevel(playerUuid), 399 + gamemode = getCurrentGamemode(playerUuid), 400 + syncedAt = java.time.Instant.now().toString() 401 + ) 402 + 403 + // Create the record 404 + val ref = recordManager.createTypedRecord( 405 + playerUuid = playerUuid, 406 + collection = "com.jollywhoppers.minecraft.player.stats", 407 + record = stats 408 + ).getOrThrow() 409 + 410 + logger.info("Stats synced successfully: ${ref.uri}") 411 + } 412 + 413 + /** 414 + * Complete example: Achievement unlocked workflow 415 + */ 416 + suspend fun onAchievementUnlocked( 417 + playerUuid: UUID, 418 + achievementKey: String 419 + ): Result<Unit> = runCatching { 420 + // Check if already unlocked by listing existing achievements 421 + val existing = recordManager.listAllRecords( 422 + playerUuid = playerUuid, 423 + collection = "com.jollywhoppers.minecraft.achievement" 424 + ).getOrThrow() 425 + 426 + val alreadyUnlocked = existing.any { recordData -> 427 + try { 428 + val achievement = kotlinx.serialization.json.Json.decodeFromJsonElement<Achievement>(recordData.value) 429 + achievement.achievementId == achievementKey 430 + } catch (e: Exception) { 431 + false 432 + } 433 + } 434 + 435 + if (alreadyUnlocked) { 436 + logger.info("Achievement $achievementKey already unlocked") 437 + return@runCatching 438 + } 439 + 440 + // Create new achievement record 441 + val achievement = Achievement( 442 + `$type` = "com.jollywhoppers.minecraft.achievement", 443 + player = PlayerRef(playerUuid.toString(), "Player"), 444 + achievementId = achievementKey, 445 + achievementName = getAchievementName(achievementKey), 446 + achievedAt = java.time.Instant.now().toString(), 447 + category = getAchievementCategory(achievementKey) 448 + ) 449 + 450 + recordManager.createTypedRecord( 451 + playerUuid = playerUuid, 452 + collection = "com.jollywhoppers.minecraft.achievement", 453 + record = achievement 454 + ).getOrThrow() 455 + 456 + logger.info("New achievement unlocked: $achievementKey") 457 + } 458 + 459 + // ============================================================================ 460 + // Helper Methods (Mock implementations) 461 + // ============================================================================ 462 + 463 + private fun gatherMinecraftStats(uuid: UUID): List<Statistic> { 464 + // In real implementation, query Minecraft's StatisticsManager 465 + return listOf( 466 + Statistic("minecraft:killed.minecraft.zombie", 42), 467 + Statistic("minecraft:mined.minecraft.diamond_ore", 15) 468 + ) 469 + } 470 + 471 + private fun calculatePlaytime(uuid: UUID): Int { 472 + // In real implementation, calculate from play time stat 473 + return 180 474 + } 475 + 476 + private fun getPlayerLevel(uuid: UUID): Int { 477 + // In real implementation, get from player.experienceLevel 478 + return 25 479 + } 480 + 481 + private fun getCurrentGamemode(uuid: UUID): String { 482 + // In real implementation, get from player.gameMode 483 + return "survival" 484 + } 485 + 486 + private fun getAchievementName(key: String): String { 487 + // In real implementation, look up from Minecraft's advancement system 488 + return "Example Achievement" 489 + } 490 + 491 + private fun getAchievementCategory(key: String): String { 492 + // In real implementation, categorize by advancement tree 493 + return "adventure" 494 + } 495 + 496 + // ============================================================================ 497 + // Data Classes 498 + // ============================================================================ 499 + 500 + @Serializable 501 + data class PlayerRef( 502 + val uuid: String, 503 + val username: String 504 + ) 505 + 506 + @Serializable 507 + data class Statistic( 508 + val key: String, 509 + val value: Int 510 + ) 511 + 512 + @Serializable 513 + data class PlayerStats( 514 + val `$type`: String, 515 + val player: PlayerRef, 516 + val statistics: List<Statistic>, 517 + val playtimeMinutes: Int, 518 + val level: Int, 519 + val gamemode: String, 520 + val syncedAt: String 521 + ) 522 + 523 + @Serializable 524 + data class PlayerProfile( 525 + val `$type`: String, 526 + val player: PlayerRef, 527 + val displayName: String?, 528 + val bio: String?, 529 + val publicStats: Boolean, 530 + val publicSessions: Boolean, 531 + val updatedAt: String 532 + ) 533 + 534 + @Serializable 535 + data class Achievement( 536 + val `$type`: String, 537 + val player: PlayerRef, 538 + val achievementId: String, 539 + val achievementName: String, 540 + val achievedAt: String, 541 + val category: String 542 + ) 543 + } 544 + 545 + /** 546 + * Quick Usage Examples: 547 + * 548 + * ```kotlin 549 + * val examples = RecordManagerExamples(sessionManager) 550 + * 551 + * // Create a stats snapshot 552 + * examples.createPlayerStats(playerUuid) 553 + * .onSuccess { uri -> logger.info("Created: $uri") } 554 + * .onFailure { error -> logger.error("Failed", error) } 555 + * 556 + * // Get player profile 557 + * examples.getPlayerProfile(playerUuid) 558 + * .onSuccess { profile -> displayProfile(profile) } 559 + * .onFailure { error -> showError(error) } 560 + * 561 + * // List recent achievements 562 + * examples.listRecentAchievements(playerUuid, limit = 5) 563 + * .onSuccess { achievements -> 564 + * achievements.forEach { logger.info(it.achievementName) } 565 + * } 566 + * 567 + * // Update profile safely 568 + * examples.safelyUpdateProfile(playerUuid, "New Display Name") 569 + * .onSuccess { logger.info("Profile updated") } 570 + * 571 + * // Sync on logout 572 + * examples.onPlayerLogout(playerUuid, player.name) 573 + * ``` 574 + */