diff --git a/.claude/skills/lightning-server.md b/.claude/skills/lightning-server.md index 2b46ef452..c6d2a5c9a 100644 --- a/.claude/skills/lightning-server.md +++ b/.claude/skills/lightning-server.md @@ -161,10 +161,13 @@ Both patterns add a WebSocket endpoint that provides: **Manual Database Operations (use only when needed)** -Use low-level database operations for custom business logic beyond simple CRUD: +Use low-level database operations for custom business logic beyond simple CRUD. Define a +each table once in your `ServerBuilder` with `registerTable` — one call defines it, registers it, and +creates its once-per-deploy prepare task. Invoke the result inside a handler to get the `Table`: ```kotlin -val posts = database().table() +val postTable = database.registerTable("Post") // in your ServerBuilder: define + register + prepare +val posts = postTable() // inside a handler: the live Table // Insert posts.insertOne(Post(title = "Hello", content = "World")) @@ -325,12 +328,14 @@ val sendEmail = path.path("send-email").post bind HttpHandler { request -> HttpResponse.plainText("Email queued") } +val oldDataTable = database.registerTable("OldData") // define + register + prepare, once + // Scheduled task val cleanup = path.path("scheduled-cleanup") bind ScheduledTask( frequency = 1.hours ) { println("Running cleanup...") - database().table().deleteMany(condition { + oldDataTable().deleteMany(condition { it.createdAt lt Clock.System.now() - 30.days }) } @@ -351,11 +356,12 @@ val value = cache().get("key") cache().remove("key") // Cache-aside pattern +// dataTable is registered in your ServerBuilder: val dataTable = database.registerTable("Data") suspend fun getExpensiveData(id: String): Data { val cached = cache().get("data:$id") if (cached != null) return cached - val fresh = database().table().get(id) + val fresh = dataTable().get(id) cache().set("data:$id", fresh, expire = 10.minutes) return fresh } diff --git a/CLAUDE.md b/CLAUDE.md index 2685e474f..451b4897b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,10 +236,16 @@ data class Post( ) : HasId ``` -Database operations use a DSL for type-safe queries: +Database operations use a DSL for type-safe queries. Register each table once in your `ServerBuilder` +with `registerTable` — one call defines it, registers it, and creates its once-per-deploy prepare task. +The returned value is a runtime accessor; invoke it inside a handler to get the `Table`: ```kotlin -val posts = database().table() +// in your ServerBuilder: +val postTable = database.registerTable("Post") // define + register + prepare, once + +// inside a handler: +val posts = postTable() // Insert posts.insertOne(Post(title = "Test", author = "user@example.com", body = "Content")) diff --git a/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/PrincipalType.kt b/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/PrincipalType.kt index 28c4a4bd7..00ab867fa 100644 --- a/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/PrincipalType.kt +++ b/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/PrincipalType.kt @@ -36,10 +36,11 @@ import kotlin.time.Duration.Companion.minutes * companion object : PrincipalType { * override val idSerializer = Uuid.serializer() * override val subjectSerializer = serializer() + * val table = DatabaseTableDefinition() // define once, reuse everywhere * * context(server: ServerRuntime) * override suspend fun fetch(id: Uuid): User { - * return database().table().get(id) + * return database().table(table).get(id) * ?: throw NotFoundException("User not found") * } * } @@ -199,7 +200,7 @@ public interface PrincipalType, ID : Comparable> { * Consider adding a registration mechanism for indexed properties: * ```kotlin * val indices = mapOf( - * "email" to { email: String -> database().table().find { it.email eq email }.first() } + * "email" to { email: String -> database().table(userTable).find { it.email eq email }.first() } * ) * ``` * diff --git a/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md b/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md index 9cd9fd0ff..93cfd84f7 100644 --- a/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md +++ b/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md @@ -118,9 +118,13 @@ data class User( override val idSerializer = Uuid.serializer() override val subjectSerializer = serializer() + // The table itself lives on the ServerBuilder, declared once: + // val userTable = database.registerTable("User") + // registerTable requires a ServerBuilder in context, so it cannot be + // declared here — reference it instead. context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User { - return database().table().get(id) + return Server.userTable().get(id) ?: throw NotFoundException("User not found") } diff --git a/core/src/main/kotlin/com/lightningkite/lightningserver/definition/Extensions.kt b/core/src/main/kotlin/com/lightningkite/lightningserver/definition/Extensions.kt index f7784d19f..094c281db 100644 --- a/core/src/main/kotlin/com/lightningkite/lightningserver/definition/Extensions.kt +++ b/core/src/main/kotlin/com/lightningkite/lightningserver/definition/Extensions.kt @@ -44,7 +44,17 @@ public interface Extensions { public class MutableExtensions() : Extensions { public constructor(start: Extensions) : this() { for ((key, value) in start.entries) { - map[key] = value + if (key is WritableKey<*, *>) { + // Rehydrate the mutable WRITE form: [start] may be sealed (e.g. a ListRegistry stored + // as a SealableList), and a mutable copy must be writable so later include()/merge works. + @Suppress("UNCHECKED_CAST") + key as WritableKey + val fresh = key.default() + key.run { fresh.include(value) } + map[key] = fresh + } else { + map[key] = value + } } } diff --git a/core/src/main/kotlin/com/lightningkite/lightningserver/http/HttpHandler.kt b/core/src/main/kotlin/com/lightningkite/lightningserver/http/HttpHandler.kt index 40e7634c7..e90ca6193 100644 --- a/core/src/main/kotlin/com/lightningkite/lightningserver/http/HttpHandler.kt +++ b/core/src/main/kotlin/com/lightningkite/lightningserver/http/HttpHandler.kt @@ -47,9 +47,12 @@ public interface HttpHandler { * * Example: * ```kotlin + * // Define the table once and share it; it is the key backends use to locate the table. + * val userTable = DatabaseTableDefinition() + * * val getUser = path.path("users").arg("id").get bind HttpHandler { request -> * val userId = request.path.arg1 - * val user = database().table().get(userId) + * val user = database().table(userTable).get(userId) * HttpResponse.json(user) * } * ``` diff --git a/core/src/main/kotlin/com/lightningkite/lightningserver/http/index.md b/core/src/main/kotlin/com/lightningkite/lightningserver/http/index.md index 8e3dffc79..bf4fa3e83 100644 --- a/core/src/main/kotlin/com/lightningkite/lightningserver/http/index.md +++ b/core/src/main/kotlin/com/lightningkite/lightningserver/http/index.md @@ -45,9 +45,13 @@ includes types for requests, responses, headers, status codes, URL parsing, and ### Basic Request Handler ```kotlin +// registerTable defines the table, registers it, and creates its once-per-deploy prepare +// task. Declare it once on your ServerBuilder; invoke it to get the live table. +val userTable = database.registerTable("User") + val endpoint = path.path("users").arg("id").get bind HttpHandler { request -> val userId = request.path.arg1 - val user = database().table().get(userId) + val user = userTable().get(userId) HttpResponse.json(user) } ``` diff --git a/core/src/test/kotlin/com/lightningkite/lightningserver/definition/ExtensionsTest.kt b/core/src/test/kotlin/com/lightningkite/lightningserver/definition/ExtensionsTest.kt index 769991e99..cfbac2cc1 100644 --- a/core/src/test/kotlin/com/lightningkite/lightningserver/definition/ExtensionsTest.kt +++ b/core/src/test/kotlin/com/lightningkite/lightningserver/definition/ExtensionsTest.kt @@ -38,6 +38,24 @@ class ExtensionsTest { assertEquals(listOf("item"), ext[TestWritableKey]) } + @Test + fun `writable key survives seal then copy then include`() { + // Regression: a sealed WritableKey value (e.g. a ListRegistry stored as a SealableList) must + // rehydrate to its mutable WRITE form when copied into a new MutableExtensions, so a later + // include() can still merge it. This is the seal -> flatten -> merge cycle that ServerDefinition + // performs across modules; previously it threw ClassCastException. + val original = MutableExtensions() + original[TestWritableKey].add("a") + + val copy = original.sealed().toMutableExtensions() + + val other = MutableExtensions() + other[TestWritableKey].add("b") + copy.include(other) + + assertEquals(listOf("a", "b"), copy[TestWritableKey]) + } + class TestExtendable : Extendable { override val extensions = MutableExtensions() } diff --git a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/BlogEndpoints.kt b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/BlogEndpoints.kt index 1afa6e761..13e8b5e6c 100644 --- a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/BlogEndpoints.kt +++ b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/BlogEndpoints.kt @@ -13,6 +13,7 @@ object BlogEndpoints : ServerBuilder() { // The correct way of doing it val info = Server.database.modelInfo( auth = Server.UserAuth.require(), + tableName = "BlogPost", permissions = { if (auth.fetch().isSuperUser) ModelPermissions.allowAll() diff --git a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt index 38d5e6b0a..e019a91f5 100644 --- a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt +++ b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt @@ -110,14 +110,19 @@ object Server : ServerBuilder() { RedisPubSub } - val setupAdmins = path.path("setup-admins2") bind startupOnce(database) { - userInfo.table().insertOne( - User( - email = "joseph@lightningkite.com", - isSuperUser = true, - phone = "+18013693729".toPhoneNumber() + // Seed an admin user. This runs as a pre-deploy task (once per deploy, before the new version + // serves), and `doOnce` guards the actual insert so the seed happens only once ever, not on + // every deploy - the sanctioned pattern for "run exactly once" pre-deploy work. + val setupAdmins = path.path("setup-admins2") bind PreDeployTask { + doOnce("setup-admins2", database) { + userInfo.table().insertOne( + User( + email = "joseph@lightningkite.com", + isSuperUser = true, + phone = "+18013693729".toPhoneNumber() + ) ) - ) + } } object UserAuth : PrincipalType { @@ -142,6 +147,7 @@ object Server : ServerBuilder() { val userInfo: ModelInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "User", permissions = { val user = authOrNull?.fetch() val everyone: Condition = Condition.Always diff --git a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/endpoints/DatabaseExamplesEndpoints.kt b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/endpoints/DatabaseExamplesEndpoints.kt index 35acd2b69..142b279fb 100644 --- a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/endpoints/DatabaseExamplesEndpoints.kt +++ b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/endpoints/DatabaseExamplesEndpoints.kt @@ -2,6 +2,7 @@ package com.lightningkite.lightningserver.demo.endpoints import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.noAuth +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.Runtime import com.lightningkite.lightningserver.definition.builder.ServerBuilder import com.lightningkite.lightningserver.demo.models.* @@ -9,6 +10,7 @@ import com.lightningkite.lightningserver.demo.models.status import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.pathing.arg1 import com.lightningkite.lightningserver.typed.ApiHttpHandler +import com.lightningkite.lightningserver.typed.registerTable import com.lightningkite.lightningserver.typed.route import com.lightningkite.services.database.* import kotlinx.coroutines.flow.toList @@ -54,6 +56,11 @@ class DatabaseExamplesEndpoints( private val database: Runtime, ) : ServerBuilder() { + // Table definitions identify each table; prepare them once per deploy (before serving) so the + // collection/indexes exist, then access them at runtime with database().table(def). + private val postTable = database.registerTable("BlogPost") + private val commentTable = database.registerTable("Comment") + /** * POST /blog/posts * @@ -85,7 +92,7 @@ class DatabaseExamplesEndpoints( status = PostStatus.DRAFT ) - database().table().insertOne(post) + postTable().insertOne(post) } ) @@ -103,7 +110,7 @@ class DatabaseExamplesEndpoints( implementation = { _: Unit -> // Note: Query parameters would need to be passed differently in ApiHttpHandler // For this example, we'll return all published posts - val posts = database().table() + val posts = postTable() // Simple condition for published posts val condition: Condition = condition { it.status eq PostStatus.PUBLISHED } @@ -141,7 +148,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.OK, implementation = { _: Unit -> val id = route.arg1 - val posts = database().table() + val posts = postTable() val post = posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -171,7 +178,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.OK, implementation = { input: UpdatePostRequest -> val id = route.arg1 - val posts = database().table() + val posts = postTable() // Check if post exists posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -220,8 +227,8 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.NoContent, implementation = { _: Unit -> val id = route.arg1 - val posts = database().table() - val comments = database().table() + val posts = postTable() + val comments = commentTable() // Check if post exists posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -251,7 +258,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.Created, implementation = { input: CreateCommentRequest -> val postId = route.arg1 - val posts = database().table() + val posts = postTable() // Verify post exists posts.get(postId) ?: throw NotFoundException("Blog post not found") @@ -267,7 +274,7 @@ class DatabaseExamplesEndpoints( parentCommentId = input.parentCommentId ) - database().table().insertOne(comment) + commentTable().insertOne(comment) } ) @@ -285,7 +292,7 @@ class DatabaseExamplesEndpoints( implementation = { _: Unit -> val postId = route.arg1 - database().table() + commentTable() .find( condition = condition { (it.postId eq postId) and (it.isApproved eq true) @@ -308,7 +315,7 @@ class DatabaseExamplesEndpoints( auth = noAuth, successCode = HttpStatus.OK, implementation = { input: SearchPostsRequest -> - val posts = database().table() + val posts = postTable() // Start with published posts only var condition: Condition = condition { it.status eq PostStatus.PUBLISHED } diff --git a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/main.kt b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/main.kt index d70921c45..6d0474502 100644 --- a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/main.kt +++ b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/main.kt @@ -63,6 +63,23 @@ private fun predeploy() { println("Pre-deploy complete") } +/** + * Local development convenience: run pre-deploy tasks (DB reconciliation, etc.) and then serve, in a + * single process. In production these are separate: the pipeline runs `predeploy` before cutover, + * and instances run `serve`. + */ +private fun dev() { + val before = TimeSource.Monotonic.markNow() + val built = Server.build() + println("Server built in ${before.elapsedNow()}") + KtorEngine(built).apply { + settings.loadFromFile(KFile("settings.json"), internalSerializersModule) + settings.ready() + runPreDeployTasksBlocking() + start(Netty) + } +} + fun sdk() { println("Writing SDK") FetcherSdk("com.lightningkite.lightningserver.demo").writeUsingDefaultSettings( @@ -115,7 +132,7 @@ fun settingsSchema(output: File = File("settings.schema.json")) { fun main(vararg args: String) { cli( arguments = args, - available = listOf(::serve, ::serveJdk, ::serveNetty, ::predeploy, ::sdk, ::apiBaselineWrite, ::apiCheck, ::settingsSchema), + available = listOf(::serve, ::serveJdk, ::serveNetty, ::predeploy, ::dev, ::sdk, ::apiBaselineWrite, ::apiCheck, ::settingsSchema), ) } diff --git a/demo/src/test/kotlin/com/lightningkite/lightningserver/demo/TestModelTest.kt b/demo/src/test/kotlin/com/lightningkite/lightningserver/demo/TestModelTest.kt index 25ee912f7..e304f15e5 100644 --- a/demo/src/test/kotlin/com/lightningkite/lightningserver/demo/TestModelTest.kt +++ b/demo/src/test/kotlin/com/lightningkite/lightningserver/demo/TestModelTest.kt @@ -9,6 +9,9 @@ import org.junit.Test import kotlin.test.* class TestModelTest { + private val testModelTable = DatabaseTableDefinition() + private val userTable = DatabaseTableDefinition() + @Test fun testModelCreation() = runBlocking { @@ -44,7 +47,7 @@ class TestModelTest { fun testDatabaseInsertAndRead() = runBlocking { TestHelper.testServer { val db = Server.database() - val collection = db.table() + val collection = db.table(testModelTable) val testItem = TestModel( name = "Database Test", @@ -67,7 +70,7 @@ class TestModelTest { fun testDatabaseQuery() = runBlocking { TestHelper.testServer { val db = Server.database() - val collection = db.table() + val collection = db.table(testModelTable) // Insert test data val item1 = TestModel(name = "Item 1", number = 1, status = Status.DRAFT) @@ -88,7 +91,7 @@ class TestModelTest { fun testDatabaseUpdate() = runBlocking { TestHelper.testServer { val db = Server.database() - val collection = db.table() + val collection = db.table(testModelTable) val testItem = TestModel(name = "Original Name", number = 50) collection.insertOne(testItem) @@ -109,7 +112,7 @@ class TestModelTest { fun testDatabaseDelete() = runBlocking { TestHelper.testServer { val db = Server.database() - val collection = db.table() + val collection = db.table(testModelTable) val testItem = TestModel(name = "To Delete", number = 999) collection.insertOne(testItem) @@ -142,7 +145,7 @@ class TestModelTest { fun testUserDatabaseOperations() = runBlocking { TestHelper.testServer { val db = Server.database() - val users = db.table() + val users = db.table(userTable) val newUser = User( email = "newuser@example.com", diff --git a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsEc2BuilderBase.kt b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsEc2BuilderBase.kt index 5fc8dd08f..ad3c09b07 100644 --- a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsEc2BuilderBase.kt +++ b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsEc2BuilderBase.kt @@ -122,6 +122,14 @@ public abstract class TerraformAwsEc2BuilderBase( /** Command to start the server (passed to main class). */ public open val serverCommand: String get() = "serve" + /** + * Command that runs pre-deploy tasks and exits (passed to the app launcher). Run once per + * deploy, before the new version is cut over, while the current version keeps serving; a + * non-zero exit aborts the deploy. The application must expose this command (see the framework's + * `runPreDeploy`). Defaults to `"predeploy"`. + */ + public open val preDeployCommand: String get() = "predeploy" + /** Whether this is a debug deployment. */ public abstract val debug: Boolean @@ -943,6 +951,66 @@ REDEPLOY_EOF chmod +x /usr/local/bin/lightning-server-redeploy echo "[INFO] Creating Lightning Server Redeploy Script - DONE" + +# === Lightning Server Pre-Deploy Script === +# Runs pre-deploy tasks with the NEW build in a scratch dir, without touching the live server, so +# the current version keeps serving while migrations run. Invoked once per deploy (before the +# redeploy/cutover) - by the single-instance redeploy on the one box, and by the scaling fleet +# script on one instance. A non-zero exit aborts the deploy. +echo "[INFO] Creating Lightning Server Pre-Deploy Script" +cat > /usr/local/bin/lightning-server-predeploy << 'PREDEPLOY_EOF' +#!/bin/bash +set -euo pipefail + +log() { echo "[lightning-server-predeploy] $*"; } +err() { echo "[lightning-server-predeploy] ERROR: $*" >&2; } + +$$bucketRegionResolution +SSM_PARAM="/$$projectPrefix/settings-password" +SCRATCH="/opt/lightning-server/predeploy" +LOG_FILE="/var/log/$$projectPrefix/predeploy.log" + +mkdir -p "$(dirname "$LOG_FILE")" +touch "$LOG_FILE" +chown ubuntu:ubuntu "$LOG_FILE" +exec > >(tee -a "$LOG_FILE" | logger -t lightning-server-predeploy -s) 2>&1 + +log "Pre-deploy started at $(date)" + +# Fetch the NEW build + settings into a scratch dir; the live server is never touched. +rm -rf "$SCRATCH" +mkdir -p "$SCRATCH" +aws s3 cp "s3://$BUCKET/server.zip" "$SCRATCH/server.zip" --region "$REGION" --no-progress +unzip -q "$SCRATCH/server.zip" -d "$SCRATCH" +aws s3 cp "s3://$BUCKET/settings.enc" "$SCRATCH/settings.enc" --region "$REGION" --no-progress +SETTINGS_PASS=$(aws ssm get-parameter --name "$SSM_PARAM" --with-decryption --query Parameter.Value --output text --region "$REGION") +if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -md sha256 \ + -in "$SCRATCH/settings.enc" -out "$SCRATCH/settings.json" -pass pass:"$SETTINGS_PASS"; then + err "Failed to decrypt settings" + rm -rf "$SCRATCH" + exit 1 +fi +rm -f "$SCRATCH/settings.enc" +chown -R ubuntu:ubuntu "$SCRATCH" +chmod 600 "$SCRATCH/settings.json" + +# Cap heap so this runs alongside the live server without risking OOM. Heavy migrations may need more. +log "Running pre-deploy tasks with the new version" +cd "$SCRATCH" +if sudo -u ubuntu env "JAVA_OPTS=-Xmx512m" ./server/bin/server $$preDeployCommand; then + cd / + rm -rf "$SCRATCH" + log "Pre-deploy complete" +else + cd / + rm -rf "$SCRATCH" + err "Pre-deploy tasks failed" + exit 1 +fi +PREDEPLOY_EOF + +chmod +x /usr/local/bin/lightning-server-predeploy +echo "[INFO] Creating Lightning Server Pre-Deploy Script - DONE" """ ) } @@ -973,6 +1041,11 @@ echo "[INFO] Creating Lightning Server Redeploy Script - DONE" "Invalid server command '$serverCommand': contains potentially dangerous characters" } + // Validate pre-deploy command + require(preDeployCommand.none { it in dangerousChars }) { + "Invalid pre-deploy command '$preDeployCommand': contains potentially dangerous characters" + } + // Validate instance file names (must be absolute, no traversal) for ((name, _) in instanceFiles.keys + instanceFilesRaw.keys) { require(name.matches(Regex("[A-Za-z0-9._-]+"))) { diff --git a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsScalingEc2Builder.kt b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsScalingEc2Builder.kt index a002d469c..69bd2e1bb 100644 --- a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsScalingEc2Builder.kt +++ b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsScalingEc2Builder.kt @@ -952,6 +952,34 @@ run_redeploy() { return 1 } +# Run pre-deploy tasks on one instance via SSM (in a scratch dir; the live server is untouched). +# Returns non-zero on any failure so the rollout can abort before touching the fleet. +run_predeploy() { + local id="$1" + local cmd_id + cmd_id=$(aws ssm send-command \ + --instance-ids "$id" \ + --document-name "AWS-RunShellScript" \ + --comment "terraform pre-deploy" \ + --parameters 'commands=/usr/local/bin/lightning-server-predeploy,executionTimeout=600' \ + --region "$REGION" --query 'Command.CommandId' --output text) + for i in $(seq 1 180); do + status=$(aws ssm get-command-invocation --command-id "$cmd_id" --instance-id "$id" \ + --region "$REGION" --query 'Status' --output text 2>/dev/null || echo "Pending") + case "$status" in + Success) return 0 ;; + Cancelled|Failed|TimedOut) + err "pre-deploy on $id finished with status: $status" + aws ssm get-command-invocation --command-id "$cmd_id" --instance-id "$id" \ + --region "$REGION" --query 'StandardErrorContent' --output text >&2 || true + return 1 ;; + *) sleep 5 ;; + esac + done + err "pre-deploy on $id did not finish within polling window" + return 1 +} + # Drain -> redeploy -> validate healthy, for one instance. Returns non-zero on any failure. process_instance() { local id="$1" @@ -990,6 +1018,17 @@ if [ -z "$IDS" ]; then exit 0 fi +# Run pre-deploy tasks once, on one instance, before rolling the fleet. The whole fleet keeps +# serving the previous version while these run; a failure aborts the rollout (the EXIT trap resumes +# ASG processes and re-registers every instance, so the fleet is left untouched and serving). +FIRST_ID="${IDS%%[[:space:]]*}" +log "Running pre-deploy tasks on $FIRST_ID before rolling the fleet" +wait_ssm_online "$FIRST_ID" || exit 1 +if ! run_predeploy "$FIRST_ID"; then + err "Pre-deploy tasks failed; aborting rollout." + exit 1 +fi + # Process in batches of $BATCH, in parallel within a batch; fail the whole run if any member fails. batch=() flush_batch() { diff --git a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt index 996b4b0dc..2c2935d54 100644 --- a/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt +++ b/deploy-aws-ec2/src/main/kotlin/com/lightningkite/lightningserver/terraform/aws/ec2/TerraformAwsSingleEc2Builder.kt @@ -803,6 +803,50 @@ for i in $(seq 1 180); do fi done +# Run pre-deploy tasks first (new build in a scratch dir; the running server is untouched). If they +# fail, abort without redeploying so the current version keeps serving. +log "Sending SSM command to run /usr/local/bin/lightning-server-predeploy..." +PRE_CMD_ID=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "terraform pre-deploy" \ + --parameters 'commands=/usr/local/bin/lightning-server-predeploy,executionTimeout=600' \ + --region "$REGION" \ + --query 'Command.CommandId' \ + --output text) +log "Pre-deploy command ID: $PRE_CMD_ID — polling for completion..." +for i in $(seq 1 180); do + status=$(aws ssm get-command-invocation \ + --command-id "$PRE_CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --region "$REGION" \ + --query 'Status' \ + --output text 2>/dev/null || echo "Pending") + case "$status" in + Success) + log "Pre-deploy succeeded" + break + ;; + Cancelled|Failed|TimedOut) + err "Pre-deploy finished with status: $status — aborting without redeploy" + aws ssm get-command-invocation \ + --command-id "$PRE_CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --region "$REGION" \ + --query 'StandardErrorContent' \ + --output text >&2 || true + exit 1 + ;; + *) + sleep 5 + ;; + esac + if [ "$i" = "180" ]; then + err "Pre-deploy did not finish within polling window" + exit 1 + fi +done + log "Sending SSM command to run /usr/local/bin/lightning-server-redeploy..." CMD_ID=$(aws ssm send-command \ --instance-ids "$INSTANCE_ID" \ diff --git a/docs-guide/drafts/advanced-database.md b/docs-guide/drafts/advanced-database.md index a52e083f9..b04743036 100644 --- a/docs-guide/drafts/advanced-database.md +++ b/docs-guide/drafts/advanced-database.md @@ -67,7 +67,7 @@ and a direction: ```kotlin // Illustrative. -val posts = database().table() +val posts = postTable() // Ten most-recently updated posts. val recent = posts.find( @@ -120,7 +120,7 @@ Count the number of matching documents: ```kotlin // Illustrative. -val table = database().table() +val table = postTable() // Total number of posts. val total: Int = table.count() @@ -269,7 +269,7 @@ list of stored documents. The `insertMany` convenience extension wraps it: ```kotlin // Illustrative. -val table = database().table() +val table = postTable() val newPosts = listOf( Post(title = "First", author = "alice@example.com", body = "..."), @@ -374,7 +374,7 @@ your table reference, usually inside a lazy property or a helper: ```kotlin // Illustrative — setting this up in a helper property. fun notesTableWithHooks(): Table = - database().table() + noteTable() .postCreate { note -> // Runs after every successful insert or upsert-that-inserted. println("Note created: ${note._id}") @@ -421,7 +421,7 @@ another system — a PubSub channel, a websocket topic, or an audit log: ```kotlin // Illustrative. -val table = database().table() +val table = postTable() .withChangeListener { changes -> for (change in changes.changes) { // Forward to a PubSub channel so other instances know. diff --git a/docs-guide/drafts/media.md b/docs-guide/drafts/media.md index d10a8c2c2..d87b3a49f 100644 --- a/docs-guide/drafts/media.md +++ b/docs-guide/drafts/media.md @@ -123,23 +123,35 @@ There are two strategies. Choose based on whether previews must be ready and every modification that touches the image field triggers processing *before* the operation completes. The API response is held until previews are written. +The wrapping belongs in `modelInfo`'s `signals` hook — that hook runs with a +`ServerRuntime` in context, which is what materialises the `Table`: + ```kotlin -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) - val products = database() - .table() - .interceptImagesForProcessingNotNull( - MediaPreviewOptions(sizeInPixels = 200), // thumbnail - MediaPreviewOptions(sizeInPixels = 1200), // full-size web - makePath = { it.path { it.photo } } - ) + val productInfo = database.modelInfo?, Product, Uuid>( + auth = noAuth, + tableName = "Product", + signals = { table -> + table.interceptImagesForProcessingNotNull( + MediaPreviewOptions(sizeInPixels = 200), // thumbnail + MediaPreviewOptions(sizeInPixels = 1200), // full-size web + makePath = { it.photo }, + ) + }, + permissions = { ModelPermissions.allowAll() }, + ) + + val products = path.path("products") include ModelRestEndpoints(productInfo) } ``` Use `interceptImagesForProcessing` (with the trailing-`NotNull` dropped) when -the field is nullable (`ServerFileWithMetadata?`). +the field is nullable (`ServerFileWithMetadata?`). The `NotNull` variant exists +because `makePath` must produce a `DataClassPath` and +`DataClassPath` is invariant in its value type — a path to a non-nullable field +does not fit without it. **When to use this strategy:** - Previews must exist the moment the record is readable by clients. @@ -151,24 +163,28 @@ the field is nullable (`ServerFileWithMetadata?`). model and schedules processing work outside the request lifecycle. ```kotlin -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) // The task must be bound to a path or it will never execute. val processProductImages = - path.path("tasks").path("process-product-images").task bind + path.path("tasks").path("process-product-images") bind processImagesInBackground( info = productInfo, // ModelInfo MediaPreviewOptions(sizeInPixels = 200), MediaPreviewOptions(sizeInPixels = 1200), timeout = 5.minutes, - makePath = { it.path { it.photo } } + makePath = { it.photo }, ) } ``` -> **Important:** If you skip the `path.task bind` binding, the task object is +There is no `NotNull` variant of `processImagesInBackground`, so this strategy +requires the image field to be declared nullable (`ServerFileWithMetadata?`). +With `Product.photo` non-nullable as declared above, either relax the field to +nullable or use Strategy 1. + +> **Important:** If you skip the `bind` binding, the task object is > never registered and the framework logs a warning: > `"processImagesInBackground is unregistered and cannot be executed."` diff --git a/docs-guide/drafts/model-realtime.md b/docs-guide/drafts/model-realtime.md index bd439ee59..963d6fb76 100644 --- a/docs-guide/drafts/model-realtime.md +++ b/docs-guide/drafts/model-realtime.md @@ -38,6 +38,7 @@ object BlogServer : ServerBuilder() { val blogInfo = database.modelInfo( auth = UserAuth.require(), + tableName = "BlogPost", permissions = { if (auth.fetch().isSuperUser) ModelPermissions.allowAll() @@ -223,6 +224,7 @@ socket.incoming.collect { updates: CollectionUpdates -> object BlogEndpoints : ServerBuilder() { val info = Server.database.modelInfo( auth = Server.UserAuth.require(), + tableName = "BlogPost", permissions = { if (auth.fetch().isSuperUser) ModelPermissions.allowAll() diff --git a/docs-guide/drafts/notifications.md b/docs-guide/drafts/notifications.md index e63c917b4..79767029f 100644 --- a/docs-guide/drafts/notifications.md +++ b/docs-guide/drafts/notifications.md @@ -385,6 +385,7 @@ daily digest, weekly, etc.) via a REST API that is auto-generated at `/subscript val subs = FrequencyCustomizableSubscriptions( info = Server.database.modelInfo, UserEventType>( auth = Server.auth, + tableName = "NotificationSendMethods", permissions = { ModelPermissions(read = condition { it.user eq auth.id }) } ), defaultEmail = Frequency.batch(60), // hourly by default @@ -448,6 +449,7 @@ formatting: object MyDispatcher : NotificationBulkDispatcher( info = Server.database.modelInfo, Notification, Uuid>( auth = Server.auth, + tableName = "Notification", permissions = { ModelPermissions(read = condition { it.user eq auth.id }) } ), cache = Server.cache, diff --git a/docs-guide/drafts/oauth.md b/docs-guide/drafts/oauth.md index 2f48724f1..4da75383e 100644 --- a/docs-guide/drafts/oauth.md +++ b/docs-guide/drafts/oauth.md @@ -295,7 +295,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetchByProperty(property: String, value: String): User? { return when (property) { - "email" -> Server.database().table() + "email" -> Server.userTable() .findOne(condition { it.email eq value }) else -> super.fetchByProperty(property, value) } @@ -311,8 +311,8 @@ If no user with that email exists, `fetchByProperty` returns `null`, and the ses context(server: ServerRuntime) override suspend fun fetchByProperty(property: String, value: String): User? { return when (property) { - "email" -> Server.database().table().findOne(condition { it.email eq value }) - ?: Server.database().table().insertOne(User(email = value)) + "email" -> Server.userTable().findOne(condition { it.email eq value }) + ?: Server.userTable().insertOne(User(email = value)) else -> super.fetchByProperty(property, value) } } diff --git a/docs-guide/drafts/permissions.md b/docs-guide/drafts/permissions.md index 190d07c16..8c99e0c6e 100644 --- a/docs-guide/drafts/permissions.md +++ b/docs-guide/drafts/permissions.md @@ -80,7 +80,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - PostServer.database().table().get(id) ?: throw NotFoundException() + PostServer.userTable().get(id) ?: throw NotFoundException() } ``` @@ -136,6 +136,7 @@ the full user object — it is cached on the token for the lifetime of the reque ```kotlin val postInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "Post", permissions = { val user: User? = authOrNull?.fetch() val isAdmin: Boolean = user?.isSuperUser == true @@ -309,8 +310,12 @@ all three layers per caller: object PostServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // Raw table access for UserAuth.fetch; ModelRestEndpoints uses postInfo below. + val userTable = database.registerTable("User") + val postInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "Post", permissions = { // authOrNull is Authentication? — null for unauthenticated callers val user: User? = authOrNull?.fetch() @@ -406,7 +411,7 @@ val publishedFeed = path.path("feed").get bind ApiHttpHandler( ) ``` -Do not call `database().table()` directly in secured endpoints — that +Do not call `postTable()` directly in secured endpoints — that bypasses all permission enforcement. ## Blog post example (from demo) @@ -418,6 +423,7 @@ real-world example: object BlogEndpoints : ServerBuilder() { val info = Server.database.modelInfo( auth = Server.UserAuth.require(), + tableName = "BlogPost", permissions = { if (auth.fetch().isSuperUser) ModelPermissions.allowAll() diff --git a/docs-guide/drafts/pubsub.md b/docs-guide/drafts/pubsub.md index 3d0246ded..24a4f681d 100644 --- a/docs-guide/drafts/pubsub.md +++ b/docs-guide/drafts/pubsub.md @@ -94,7 +94,7 @@ function; it returns once the message has been handed to the backend: // Illustrative — inside an HTTP handler. val announce = path.path("posts").path("publish").post bind HttpHandler { request -> val post = /* ... parse body ... */ - database().table().insertOne(post) + postTable().insertOne(post) // Notify all subscribers that a new post is available. pubsub().get("post-events").emit(PostEvent(postId = post._id, action = "created")) @@ -181,7 +181,7 @@ the table interceptors from [Advanced Database](advanced-database.md) with a ```kotlin // Illustrative. // 1. After each insert, publish the new post to all subscribers. -val postsTable = database().table() +val postsTable = postTable() .postCreate { post -> pubsub().get("new-posts").emit(post) } diff --git a/docs-guide/drafts/vs-django.md b/docs-guide/drafts/vs-django.md index 008a03129..f13305687 100644 --- a/docs-guide/drafts/vs-django.md +++ b/docs-guide/drafts/vs-django.md @@ -104,7 +104,7 @@ directly on the `FieldCollection` (table reference): ```kotlin // Illustrative -val posts = database().table() +val posts = postTable() .interceptCreate { value -> value.copy(slug = slugify(value.title)) } .postCreate { value -> notifySubscribers(value) } .postChange { value -> invalidateCache(value._id) } @@ -126,7 +126,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + userTable().get(id) ?: throw NotFoundException() } // Require auth on an endpoint diff --git a/docs-guide/drafts/vs-rails.md b/docs-guide/drafts/vs-rails.md index 72cac3e63..6b64cc87e 100644 --- a/docs-guide/drafts/vs-rails.md +++ b/docs-guide/drafts/vs-rails.md @@ -99,7 +99,7 @@ object PostApi : ServerBuilder() { val database = setting("database", Database.Settings()) val posts = path.path("posts") include ModelRestEndpoints( - database.modelInfo(auth = UserAuth.require(), permissions = { /* ... */ }) + database.modelInfo(auth = UserAuth.require(), tableName = "Post", permissions = { /* ... */ }) ) } ``` @@ -123,7 +123,7 @@ end ```kotlin // Lightning Server — illustrative -val posts = database().table() +val posts = postTable() .interceptCreate { value -> value.copy(slug = slugify(value.title)) } .postCreate { value -> sendNotification(value) } .postChange { value -> invalidateCache(value._id) } @@ -151,7 +151,7 @@ Lightning Server `Task`: ```kotlin // Illustrative val sendWelcomeEmail = path.path("tasks").path("welcome-email") bind Task { input: WelcomeEmailInput -> - val user = database().table().get(input.userId) ?: return@Task + val user = userTable().get(input.userId) ?: return@Task email().send(Email(subject = "Welcome!", to = listOf(input.address), html = "

Hi ${user.name}!

")) } @@ -209,7 +209,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + userTable().get(id) ?: throw NotFoundException() } // Requiring auth on an endpoint @@ -247,7 +247,7 @@ RAM-backed server in-process with no ports or external infrastructure: // Illustrative — core pattern from guide/testing.md @Test fun testGetProfile() = UserProfileServer.testBlocking(settings = { database set Database.Settings("ram") }) { - val alice = UserProfileServer.database().table() + val alice = UserProfileServer.userTable() .insertOne(User(name = "Alice", email = "alice@example.com")) val auth = UserAuth.testAuth(alice) val result = UserProfileServer.getProfile.test(auth, Unit) diff --git a/docs-guide/drafts/vs-spring.md b/docs-guide/drafts/vs-spring.md index c36653c2b..36cd9c441 100644 --- a/docs-guide/drafts/vs-spring.md +++ b/docs-guide/drafts/vs-spring.md @@ -86,7 +86,7 @@ object UserEndpoints : ServerBuilder() { errorCases = listOf(LSError(http = 404, detail = "not-found", message = "User not found")), implementation = { _: Unit -> val id = path.arg1 - database().table().get(Uuid.parse(id)) + userTable().get(Uuid.parse(id)) ?: throw NotFoundException(detail = "not-found", message = "User not found") } ) @@ -96,7 +96,7 @@ object UserEndpoints : ServerBuilder() { auth = noAuth, successCode = HttpStatus.Created, implementation = { request: CreateUserRequest -> - database().table().insertOne(request.toUser()) + userTable().insertOne(request.toUser()) } ) } @@ -129,7 +129,7 @@ data class User( ) : HasId // No repository interface — use the table directly: -val users = database().table() +val users = userTable() users.find(condition { it.email.contains("@example.com") }).toList() users.findOne(condition { (it.email eq email) and (it.active eq true) }) @@ -239,7 +239,7 @@ val getUser = path.path("users").arg("id").get bind ApiHttpHandler( val id = path.arg1 val key = "user:$id" cache().get(key) ?: run { - val user = database().table().get(Uuid.parse(id)) ?: throw NotFoundException() + val user = userTable().get(Uuid.parse(id)) ?: throw NotFoundException() cache().set(key, user, ttl = 5.minutes) user } @@ -298,7 +298,7 @@ public CompletableFuture sendWelcomeEmail(String userId) { ... } object Server : ServerBuilder() { val sendWelcomeEmail = task("send-welcome-email") { userId: Uuid -> - val user = database().table().get(userId) ?: return@task + val user = userTable().get(userId) ?: return@task email().send(welcomeEmail(user)) } @@ -306,7 +306,7 @@ object Server : ServerBuilder() { summary = "Register", auth = noAuth, implementation = { req: RegistrationRequest -> - val user = database().table().insertOne(req.toUser()) + val user = userTable().insertOne(req.toUser()) sendWelcomeEmail.launch(user._id) // fire and forget user } @@ -345,7 +345,7 @@ class UserEndpointsTest { @Test fun testGetUser() = Server.testBlocking(settings = { database.set("ram") }) { val user = User(_id = Uuid.random(), email = "test@example.com") - database().table().insertOne(user) + userTable().insertOne(user) val result = Server.getUser.test(null, Unit) // typed output, no HTTP round-trip check(result.email == "test@example.com") diff --git a/docs-guide/guide/auth.md b/docs-guide/guide/auth.md index 8538f5468..90e4eaede 100644 --- a/docs-guide/guide/auth.md +++ b/docs-guide/guide/auth.md @@ -89,7 +89,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): UserProfile = - UserProfileServer.database().table().get(id) + UserProfileServer.userProfiles().get(id) ?: throw NotFoundException("User not found") } ``` @@ -120,6 +120,10 @@ declare that the endpoint requires a `UserProfile` token. Compare this to object UserProfileServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // registerTable defines the table, registers it, and creates its once-per-deploy prepare task. + // Access it at runtime by invoking it: userProfiles(). + val userProfiles = database.registerTable("UserProfile") + init { // register() makes this principal type discoverable when deserializing tokens. register(UserAuth) @@ -176,7 +180,7 @@ Pass the resulting auth token as the first argument to the typed `.test()` call. ```kotlin fun authTest() = UserProfileServer.testBlocking(settings = { database set Database.Settings("ram") }) { // Seed a user directly into the database - val alice = UserProfileServer.database().table() + val alice = UserProfileServer.userProfiles() .insertOne(UserProfile(name = "Alice", email = "alice@example.com")) // testAuth() creates an Authentication for use in tests. @@ -243,7 +247,7 @@ val isAdmin: AuthCacheKey = authCacheKey( ) { auth -> // `auth` is the Authentication the value is derived from. // Run any suspension here — database queries, etc. - UserProfileServer.database().table() + UserProfileServer.userProfiles() .count(condition { it._id eq auth.id } and condition { it.role eq "admin" }) > 0 } ``` diff --git a/docs-guide/guide/caching.md b/docs-guide/guide/caching.md index 7cb4eeb3e..a7f689982 100644 --- a/docs-guide/guide/caching.md +++ b/docs-guide/guide/caching.md @@ -75,6 +75,8 @@ from the source of truth and populate the cache. ```kotlin // Illustrative — not a drift-checked sample. // Requires ServerRuntime in context; in production, annotate with context(server: ServerRuntime). +// `users` is the registration declared on the ServerBuilder: +// val users = database.registerTable("User") suspend fun getUser(id: Uuid): User { val key = "user:$id" @@ -84,7 +86,7 @@ suspend fun getUser(id: Uuid): User { if (cached != null) return cached // 2. Miss — load from the source of truth - val user = database().table().get(id) + val user = users().get(id) ?: throw NotFoundException("user $id not found") // 3. Populate cache for future reads @@ -98,7 +100,7 @@ On writes, invalidate or update the cached entry so stale data is not served: ```kotlin // Illustrative — not a drift-checked sample. suspend fun updateUser(id: Uuid, modification: Modification): User { - val updated = database().table().updateOneById(id, modification) + val updated = users().updateOneById(id, modification) ?: throw NotFoundException("user $id not found") // Invalidate — next read will reload from the database. diff --git a/docs-guide/guide/database.md b/docs-guide/guide/database.md index 7475ef9bf..289e3e07f 100644 --- a/docs-guide/guide/database.md +++ b/docs-guide/guide/database.md @@ -79,6 +79,10 @@ database suitable for tests and local development: object NoteDbServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // registerTable defines the table, registers it, and creates its once-per-deploy prepare task. + // Access it at runtime by invoking it: notes(). + val notes = database.registerTable("Note") + // GET /notes — list all notes val list = path.path("notes").get bind ApiHttpHandler( summary = "List all notes", @@ -86,7 +90,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.OK, errorCases = emptyList(), implementation = { _: Unit -> - database().table().find(Condition.Always).toList() + notes().find(Condition.Always).toList() } ) @@ -97,7 +101,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.Created, errorCases = emptyList(), implementation = { input: Note -> - database().table().insertOne(input) + notes().insertOne(input) } ) } @@ -105,9 +109,10 @@ object NoteDbServer : ServerBuilder() { Key points: -- **`database().table()`** — `database()` resolves the live service from - the current `ServerRuntime` (only callable inside a handler). `.table()` - returns a `Table` keyed on the serializer, so one table per model type. +- **`notes()`** — `notes` is the registration returned by `registerTable`; invoking it inside a + handler resolves the live `Table` from the current `ServerRuntime`. `registerTable` also + created the once-per-deploy task that prepares the table's collection/indexes, so you don't wire + that up yourself. - **`Condition.Always`** — matches every document. Use `condition { }` to narrow the query (shown in the test below). - **`find()` returns a `Flow`** — call `.toList()` to collect all results @@ -139,7 +144,7 @@ fun databaseTest() = NoteDbServer.testBlocking(settings = { database set Databas check(all.size == 2) // Direct table access for condition / modification / delete - val table = NoteDbServer.database().table() + val table = NoteDbServer.notes() // condition { } builds a type-safe query using generated path extensions val found = table.find(condition { it.title eq "Shopping" }).toList() diff --git a/docs-guide/guide/model-rest.md b/docs-guide/guide/model-rest.md index 7acfaa72a..1d3540d93 100644 --- a/docs-guide/guide/model-rest.md +++ b/docs-guide/guide/model-rest.md @@ -81,6 +81,7 @@ object PostRestServer : ServerBuilder() { // ID = Uuid, the primary-key type val postInfo = database.modelInfo?, Post, Uuid>( auth = noAuth, + tableName = "Post", permissions = { ModelPermissions.allowAll() } ) @@ -260,6 +261,7 @@ object AuthPostServer : ServerBuilder() { val postInfo = database.modelInfo( auth = UserAuth.require(), + tableName = "Post", permissions = { val user = auth.fetch() ModelPermissions( @@ -287,6 +289,7 @@ Allow unauthenticated callers for reads while requiring a session for writes: // Illustrative — not drift-checked. val postInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "Post", permissions = { val user = authOrNull?.fetch() ModelPermissions( @@ -312,6 +315,7 @@ client: // Illustrative — not drift-checked. val postInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "Post", permissions = { val user = authOrNull?.fetch() val self: Condition = condition { it.author eq (user?.email ?: "") } diff --git a/docs-guide/guide/validation.md b/docs-guide/guide/validation.md index 79e5134a6..becdc60b6 100644 --- a/docs-guide/guide/validation.md +++ b/docs-guide/guide/validation.md @@ -259,9 +259,11 @@ For asynchronous checks (for example, checking a database for uniqueness): ```kotlin // Illustrative. +// `users` is the registration declared on the ServerBuilder: +// val users = database.registerTable("User") AnnotationValidators { validateSuspending { value -> - if (database().table().count(condition { it.email eq value }) > 0) + if (users().count(condition { it.email eq value }) > 0) "Email is already registered" else null } diff --git a/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/AuthSamples.kt b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/AuthSamples.kt index 52b6e958e..de2c2daeb 100644 --- a/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/AuthSamples.kt +++ b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/AuthSamples.kt @@ -37,7 +37,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): UserProfile = - UserProfileServer.database().table().get(id) + UserProfileServer.userProfiles().get(id) ?: throw NotFoundException("User not found") } // endregion user-auth @@ -46,6 +46,10 @@ object UserAuth : PrincipalType { object UserProfileServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // registerTable defines the table, registers it, and creates its once-per-deploy prepare task. + // Access it at runtime by invoking it: userProfiles(). + val userProfiles = database.registerTable("UserProfile") + init { // register() makes this principal type discoverable when deserializing tokens. register(UserAuth) @@ -74,7 +78,7 @@ object UserProfileServer : ServerBuilder() { // region auth-test fun authTest() = UserProfileServer.testBlocking(settings = { database set Database.Settings("ram") }) { // Seed a user directly into the database - val alice = UserProfileServer.database().table() + val alice = UserProfileServer.userProfiles() .insertOne(UserProfile(name = "Alice", email = "alice@example.com")) // testAuth() creates an Authentication for use in tests. diff --git a/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/DatabaseSamples.kt b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/DatabaseSamples.kt index 3d7a4bc06..9419a9ee1 100644 --- a/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/DatabaseSamples.kt +++ b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/DatabaseSamples.kt @@ -30,6 +30,10 @@ data class Note( object NoteDbServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // registerTable defines the table, registers it, and creates its once-per-deploy prepare task. + // Access it at runtime by invoking it: notes(). + val notes = database.registerTable("Note") + // GET /notes — list all notes val list = path.path("notes").get bind ApiHttpHandler( summary = "List all notes", @@ -37,7 +41,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.OK, errorCases = emptyList(), implementation = { _: Unit -> - database().table().find(Condition.Always).toList() + notes().find(Condition.Always).toList() } ) @@ -48,7 +52,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.Created, errorCases = emptyList(), implementation = { input: Note -> - database().table().insertOne(input) + notes().insertOne(input) } ) } @@ -65,7 +69,7 @@ fun databaseTest() = NoteDbServer.testBlocking(settings = { database set Databas check(all.size == 2) // Direct table access for condition / modification / delete - val table = NoteDbServer.database().table() + val table = NoteDbServer.notes() // condition { } builds a type-safe query using generated path extensions val found = table.find(condition { it.title eq "Shopping" }).toList() diff --git a/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/ModelRestSamples.kt b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/ModelRestSamples.kt index a011ed5b9..7996fde1d 100644 --- a/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/ModelRestSamples.kt +++ b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/ModelRestSamples.kt @@ -34,6 +34,7 @@ object PostRestServer : ServerBuilder() { // ID = Uuid, the primary-key type val postInfo = database.modelInfo?, Post, Uuid>( auth = noAuth, + tableName = "Post", permissions = { ModelPermissions.allowAll() } ) diff --git a/docs/authentication.md b/docs/authentication.md index 697b21ac7..06ed52bfd 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -33,6 +33,10 @@ object Server : ServerBuilder() { val cache = setting("cache", Cache.Settings()) val email = setting("email", EmailService.Settings()) + // Defines the table, registers it, and creates its once-per-deploy prepare task. + // Invoke it inside a runtime context to get the live table: userTable() + val userTable = database.registerTable("User") + // Define your principal type (user authentication) object UserAuth : PrincipalType { override val idSerializer = Uuid.serializer() @@ -41,15 +45,15 @@ object Server : ServerBuilder() { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + userTable().get(id) ?: throw NotFoundException() context(server: ServerRuntime) override suspend fun fetchByProperty(property: String, value: String): User? { return when (property) { "email" -> { - val existing = database().table() + val existing = userTable() .findOne(condition { it.email eq value }) - existing ?: database().table() + existing ?: userTable() .insertOne(User(email = value)) } else -> super.fetchByProperty(property, value) @@ -104,15 +108,15 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + userTable().get(id) ?: throw NotFoundException() context(server: ServerRuntime) override suspend fun fetchByProperty(property: String, value: String): User? { return when (property) { "email" -> { // Find or create user by email - database().table().findOne(condition { it.email eq value }) - ?: database().table().insertOne(User(email = value)) + userTable().findOne(condition { it.email eq value }) + ?: userTable().insertOne(User(email = value)) } else -> super.fetchByProperty(property, value) } @@ -210,6 +214,7 @@ val optionalAuthEndpoint = path.path("optional").get bind ApiHttpHandler( ```kotlin val userInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "User", permissions = { val user = authOrNull?.fetch() val self = condition { it._id eq user?._id } diff --git a/docs/autorest.md b/docs/autorest.md index ba0555b01..81204011d 100644 --- a/docs/autorest.md +++ b/docs/autorest.md @@ -36,6 +36,7 @@ object Server : ServerBuilder() { val posts = path.path("posts") include object : ServerBuilder() { val info = database.modelInfo( auth = UserAuth.require(), + tableName = "Post", permissions = { val user = auth.fetch() ModelPermissions( @@ -62,6 +63,7 @@ object Server : ServerBuilder() { ```kotlin val postInfo = database.modelInfo( auth = UserAuth.require(), // Require authenticated user + tableName = "Post", permissions = { // Context: `auth` is the authenticated user val user = auth.fetch() @@ -96,6 +98,7 @@ You can also hide or mask certain fields based on conditions: ```kotlin val postInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "Post", permissions = { val user = authOrNull?.fetch() ModelPermissions( @@ -168,6 +171,7 @@ A common pattern is to allow anyone to read but require authentication to write: ```kotlin val postInfo = database.modelInfo( auth = UserAuth.require() or AuthRequirement.None, + tableName = "Post", permissions = { val user = authOrNull?.fetch() val isAuthenticated = user != null diff --git a/docs/compared-to-django.md b/docs/compared-to-django.md index 345b014f6..94d3a1a4e 100644 --- a/docs/compared-to-django.md +++ b/docs/compared-to-django.md @@ -179,7 +179,7 @@ Post.objects.filter(author='user@example.com').delete() **Lightning Server:** ```kotlin -val posts = database().table() +val posts = postTable() // Get all posts by an author posts.find(condition { it.author eq "user@example.com" }).toList() @@ -245,7 +245,7 @@ def post_deleted(sender, instance, **kwargs): ### Lightning Server Lifecycle Hooks ```kotlin -val collection = database().table() +val collection = postTable() .interceptCreate { value -> // Modify value before creation println("About to insert: ${value.title}") @@ -302,19 +302,19 @@ url = reverse('post-detail', args=[123]) # '/posts/123/' object Server : ServerBuilder() { // Endpoints are stored as constants val postList = path.path("posts").get bind HttpHandler { - HttpResponse.json(database().table().find(condition { it.always }).toList()) + HttpResponse.json(postTable().find(condition { it.always }).toList()) } val postDetail = path.path("posts").arg("pk").get bind HttpHandler { request -> val pk = request.path.arg1 - val post = database().table().get(pk) ?: throw NotFoundException() + val post = postTable().get(pk) ?: throw NotFoundException() HttpResponse.json(post) } val userPosts = path.path("users").arg("userId").path("posts").get bind HttpHandler { request -> val userId = request.path.arg1 HttpResponse.json( - database().table().find(condition { it.author eq userId }).toList() + postTable().find(condition { it.author eq userId }).toList() ) } } @@ -356,12 +356,12 @@ class PostListView(View): ```kotlin object PostEndpoints : ServerBuilder() { val list = path.get bind HttpHandler { - HttpResponse.json(database().table().find(condition { it.always }).toList()) + HttpResponse.json(postTable().find(condition { it.always }).toList()) } val create = path.post bind HttpHandler { request -> val data = request.body?.parse() ?: throw BadRequestException("Missing body") - val created = database().table().insertOne(data) + val created = postTable().insertOne(data) HttpResponse( body = TypedData.json(mapOf("id" to created._id)), status = HttpStatus.Created @@ -415,6 +415,7 @@ object PostApi : ServerBuilder() { // Define model info with permissions val postInfo = database.modelInfo( auth = authOptions(), + tableName = "Post", permissions = { val user = authOrNull?.fetch() ModelPermissions( @@ -467,7 +468,7 @@ val createPost = path.path("posts").post bind ApiHttpHandler<_, User?, CreatePos author = user.email, body = input.body ) - database().table().insertOne(post) + postTable().insertOne(post) post } ) @@ -562,7 +563,7 @@ object Server : ServerBuilder() { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + userTable().get(id) ?: throw NotFoundException() } // Protected endpoint diff --git a/docs/compared-to-rails.md b/docs/compared-to-rails.md index 4039df9f2..34ace80ab 100644 --- a/docs/compared-to-rails.md +++ b/docs/compared-to-rails.md @@ -152,7 +152,7 @@ end **Lightning Server:** ```kotlin -val posts = database().table() +val posts = postTable() .interceptCreate { value -> // Modify before insertion (like before_create) value.copy(slug = value.title.slugify()) @@ -204,6 +204,7 @@ object Server : ServerBuilder() { val posts = path.path("posts") include object : ServerBuilder() { val info = database.modelInfo( auth = UserAuth.require(), + tableName = "Post", permissions = { /* permission rules */ } ) val rest = path.path("rest") module ModelRestEndpoints(info) diff --git a/docs/compared-to-spring.md b/docs/compared-to-spring.md index 9a24a3553..04182297d 100644 --- a/docs/compared-to-spring.md +++ b/docs/compared-to-spring.md @@ -28,7 +28,7 @@ Kotlin's type system and explicit code. This means fewer surprises at runtime, b | `@RestController` | `ServerBuilder` object | Define endpoints explicitly | | `@Autowired` | Direct object reference | No DI container needed | | `@ConfigurationProperties` | `setting()` | Type-safe, auto-generated defaults | -| `@Repository` | `database().table()` | Type-safe query DSL | +| `@Repository` | `registerTable()` | Type-safe query DSL | | `@Scheduled` | `schedule()` | Cron, frequency, or daily time | | `@Async` | `task()` | Fire-and-forget async tasks | | Spring Security | `PrincipalType` + `AuthEndpoints` | JWT-based, multiple proof methods | @@ -109,7 +109,7 @@ object UserEndpoints : ServerBuilder() { errorCases = listOf(LSError(http = 404, detail = "not-found", message = "User not found")), implementation = { _: Unit -> val id = path.arg1 - database().table().get(Uuid.parse(id)) + userTable().get(Uuid.parse(id)) ?: throw NotFoundException("User not found") } ) @@ -119,7 +119,7 @@ object UserEndpoints : ServerBuilder() { authOptions = noAuth, successCode = HttpStatus.Created, implementation = { request: CreateUserRequest -> - database().table().insertOne(request.toUser()) + userTable().insertOne(request.toUser()) } ) } @@ -161,7 +161,7 @@ data class User( ) : HasId // Usage - no interface to define! -val users = database().table() +val users = userTable() // Find by email containing users.find(condition { it.email.contains("@example.com") }).toList() @@ -284,7 +284,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + userTable().get(id) ?: throw NotFoundException() } object Server : ServerBuilder() { @@ -410,7 +410,7 @@ object Server : ServerBuilder() { // Explicit caching - no magic cache().get(cacheKey) ?: run { - val user = database().table().get(Uuid.parse(id)) + val user = userTable().get(Uuid.parse(id)) ?: throw NotFoundException() cache().set(cacheKey, user, ttl = 5.minutes) user @@ -505,7 +505,7 @@ object Server : ServerBuilder() { summary = "Create order", authOptions = authOptions(), implementation = { order: CreateOrderRequest -> - val saved = database().table().insertOne(order.toOrder()) + val saved = orderTable().insertOne(order.toOrder()) // Fire and forget sendNotification(NotificationRequest(order.userId, "Order created!")) @@ -632,7 +632,7 @@ class UserEndpointsTest { fun testGetUser() = runBlocking { with(testRunner) { // Insert test data - Server.database().table().insertOne( + Server.userTable().insertOne( User(_id = Uuid.parse("..."), email = "test@example.com") ) diff --git a/docs/database.md b/docs/database.md index 51cce51ec..d18b057ab 100644 --- a/docs/database.md +++ b/docs/database.md @@ -46,11 +46,22 @@ data class Post( ## Accessing the database -You can now access a table of these objects like this: +Register each table once in your `ServerBuilder` with `registerTable`. A single call defines the table, +registers it (so it can be enumerated later), and creates the once-per-deploy [pre-deploy task](tasks.md) +that prepares its collection/indexes — you don't wire any of that up yourself. **Create one per model and +share it** — don't call `registerTable` again for the same table: ```kotlin -val db = database() -val posts = db.table() +object Server : ServerBuilder() { + val database = setting("database", Database.Settings()) + val postTable = database.registerTable("Post") // define + register + prepare, once +} +``` + +The value it returns is a runtime accessor — **invoke it inside a handler** to get the live `Table`: + +```kotlin +val posts = postTable() // Insert a new post posts.insertOne(Post( @@ -82,6 +93,7 @@ For more advanced use cases with permissions and authentication, use `ModelInfo` ```kotlin val postInfo = database.modelInfo( auth = UserAuth.require(), + tableName = "Post", permissions = { val user = auth.fetch() ModelPermissions( @@ -150,7 +162,7 @@ Signals occur when a change is made to the database. You can wrap a collection with actions that will occur on those changes: ```kotlin -val collection = database().table() +val collection = postTable() .interceptCreate { value -> println("About to insert: $value") value.copy(title = value.title + " (New)") diff --git a/docs/endpoints.md b/docs/endpoints.md index 0e6bd2320..5b6d5c1d6 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -195,7 +195,7 @@ val listUsers = path.path("users").get bind HttpHandler { request -> val page = request.queryParameters["page"]?.toIntOrNull() ?: 1 val limit = request.queryParameters["limit"]?.toIntOrNull() ?: 20 - val users = database().table() + val users = userTable() .find(condition { /* ... */ }, skip = (page - 1) * limit, limit = limit) .toList() @@ -625,7 +625,7 @@ Support ETags for caching: ```kotlin val getResource = path.path("resource").arg("id").get bind HttpHandler { request -> - val resource = database().table().get(request.path.arg1) + val resource = resourceTable().get(request.path.arg1) val etag = resource.hash() val clientETag = request.headers[HttpHeader.IfNoneMatch]?.root @@ -729,7 +729,7 @@ Store endpoint references for easy testing: ```kotlin val getUser = path.path("users").arg("id").get bind HttpHandler { request -> - val user = database().table().get(request.path.arg1) + val user = userTable().get(request.path.arg1) HttpResponse.json(user) } diff --git a/docs/files.md b/docs/files.md index e6b751739..5ed1dc312 100644 --- a/docs/files.md +++ b/docs/files.md @@ -1,115 +1,199 @@ # File Systems -**OUT OF DATE** +Last updated July 2025 (`version-5`) -Storing, serving, and using user-provided files is a common requirement, and as such, is built-in directly. +Storing, serving, and using user-provided files is a common requirement, so Lightning Server provides a file-system +abstraction and a set of ready-made upload endpoints. The abstraction lives in the service-abstractions library +(`com.lightningkite.services.files`), and the HTTP endpoints that wrap it live in the `files` / `files-shared` +modules. -Valid file backends that have been built so far are Local, S3, and Azure Blob Storage. SFTP is also partially -supported - it doesn't support public URLs. +Available backends include a local (KotlinX-IO) file system and AWS S3. The API is modeled loosely on Kotlin's +built-in file functions. -The API is roughly based on Kotlin's built-in file functions. +## Declaring the need for a file system -## Declaring the need for a file sysstem +Add a setting whose value is a `PublicFileSystem`: -Add a setting as follows: +```kotlin +import com.lightningkite.services.files.PublicFileSystem + +object Server : ServerBuilder() { + val files = setting("files", PublicFileSystem.Settings()) +} +``` + +To make additional backends available, reference them in an `init` block so their loaders register themselves: ```kotlin -object Server { - //... - val files = setting(name = "files", default = FilesSettings()) - //... +import com.lightningkite.services.files.s3.S3PublicFileSystem + +object Server : ServerBuilder() { + init { S3PublicFileSystem } + val files = setting("files", PublicFileSystem.Settings()) } ``` +Like every service, the setting resolves to a `Runtime`. You invoke it inside a +`ServerRuntime` context (for example, within a handler) to get the live `PublicFileSystem`. + ## Accessing files +`FileObject` is the internal handle used to read and write files. Get one from the file system's `root` and +navigate with `then`: + ```kotlin -val rootFolder = Server.files().root +import com.lightningkite.services.data.TypedData +import com.lightningkite.MediaType -val testFile = rootFolder.resolve("some/path/file.txt") +val root = files().root -// Files are written in terms of `HttpContent` -// There's no mkdir. If a folder does not exist it will be created. -testFile.put(HttpContent.Text("Hello world!", ContentType.Text.Plain)) +val testFile = root.then("some/path/file.txt") -// Files are read in terms of `HttpContent` as well -testFile.get()!!.stream().use { it: InputStream -> - it.readAllBytes() -} +// Files are written and read as TypedData. Parent folders are created implicitly. +testFile.put(TypedData.text("Hello world!", MediaType.Text.Plain)) -// You can just get metadata too -testFile.head() +// get() returns TypedData?, null if the file does not exist. +val text = testFile.get()?.text() -// Generates a file reference with a large random identifier in it. -val newFile = rootFolder.resolveRandom("test", "txt") +// Metadata only (media type, size) without downloading the body. +val info = testFile.head() -// You can list files too. -rootFolder.list().forEach { - println(it) -} +// Remove a file. +testFile.delete() ``` ## Serving files -These files have URLs that are signed for retrieval. Performing an HTTP GET will result in the file. +Files can be served over signed URLs. Performing an HTTP GET against a signed URL returns the file: ```kotlin -// Duration of the signature is determined by the file system settings +// The signature duration is determined by the file system settings. println(testFile.signedUrl) ``` -## Uploading files from a client +### `FileSystemEndpoints` -You can sign an upload URL like so: +`FileSystemEndpoints` exposes HEAD/GET/PUT handlers for a `PublicFileSystem`, including HTTP Range support on GET +for partial/resumable downloads. GET streams the bytes, HEAD returns metadata, and PUT accepts an upload to a +signed upload URL (only the local `KotlinxIoPublicFileSystem` accepts server-generated uploads this way; S3/Azure +sign their own upload URLs). ```kotlin -// Include the expiration duration -alt.uploadUrl(Duration.ofMinutes(10)) +val fileServing = path.path("files") include FileSystemEndpoints(files) ``` -Performing an HTTP PUT with the file's contents will overwrite that file. +## Uploading files from a client -## Serialization +You can sign a PUT upload URL for any file reference: -`FileObject` is a file-system resolve object which can be used to read and write files. They are purely internal to the -server. +```kotlin +import kotlin.time.Duration.Companion.minutes -`ServerFile` is a wrapper around a string that contains a public URL for an object. They are used in APIs and -serialization. +val uploadUrl = root.then("uploads/photo.jpg").uploadUrl(10.minutes) +``` -You can switch between the two using `FileObject.serverFile` and `ServerFile.fileObject`. +Performing an HTTP PUT of the file's contents to that URL writes the file. Upload URLs never grant read access. -### Security +## `ServerFile` and serialization -When a `ServerFile` is sent to a client, the url is automatically signed for reading. Therefore, if you wish to keep a -file in your file system secure, only serialize references to it for the people you want to read it. +Two types work together: -## Default File Upload Endpoints +- `FileObject` is the internal read/write handle. It never leaves the server. +- `ServerFile` (`com.lightningkite.services.files.ServerFile`) is a serializable wrapper around a URL string. + It is what you store in models and send over your API. -There is a pre-built upload endpoint for uploading files to use in subsequent requests. It requires a reference to the -intended file system to use, a database to track whether the file has been used (if it's unused, it is garbage -collected), and a `JwtSigner` setting to secure file reuse. +```kotlin +import com.lightningkite.services.files.ServerFile + +@GenerateDataClassPaths +@Serializable +data class Post( + override val _id: Uuid = Uuid.random(), + val title: String, + @MimeType("image/*") val coverImage: ServerFile? = null, +) : HasId +``` -This endpoint prevents abuse of your file system by returning two URLs: a `uploadUrl` and a `futureCallToken`. +You move between the two by resolving the `ServerFile`'s location against the file system: + +```kotlin +val obj = files().root.then(post.coverImage!!.location) // ServerFile -> FileObject +val ref = ServerFile(obj.url) // FileObject -> ServerFile +``` + +### Security -`uploadUrl` is the URL which the client should PUT their file to. +When a `ServerFile` is serialized out to a client, its URL is automatically signed for reading. Consequently, a +file stays private as long as you only serialize references to it for people you want to be able to read it. Do not +build a public file-sharing feature on top of this behavior by accident. -`futureCallToken` is a URL that can be used as a `ServerFile` in a subsequent request. +## The `UploadEarlyEndpoint` (recommended upload flow) -Neither URL will allow reading of the file, and thus, you cannot abuse this endpoint as a file-sharing system. +`UploadEarlyEndpoint` is an opinionated group of endpoints for the common "upload now, reference later" pattern. It +lets a client upload a file before the request that actually uses it, without turning your file system into an open +file-sharing service. -It is *strongly* recommended that you use this endpoint for handling files in your API rather than attempting to -implement it yourself. +Construct it with the file system, a database (used to track pending uploads for garbage collection), and a list of +`FileScanner`s (often empty): ```kotlin -val upload = UploadEarlyEndpoint(path("early-upload"), files, database, signer) +import com.lightningkite.lightningserver.files.UploadEarlyEndpoint +import com.lightningkite.lightningserver.definition.Runtime + +object Server : ServerBuilder() { + val files = setting("files", PublicFileSystem.Settings()) + val database = setting("database", Database.Settings()) + + // Register ONCE. See the warning below. + val uploadEarly = path.path("upload") module UploadEarlyEndpoint( + files = files, + database = database, + fileScanner = Runtime.Constant(listOf()), + ) +} ``` +### The flow + +1. The client calls the `endpoint` (GET) to obtain an `UploadInformation`: + - `uploadUrl` — a presigned PUT URL to upload the bytes to. + - `futureCallToken` — a token that can be sent as a serialized `ServerFile` in a later request. +2. The client PUTs the file to `uploadUrl`. +3. The client includes `futureCallToken` as the `ServerFile` value in a subsequent API call. + +Neither URL grants read access, so the endpoint cannot be abused as a file host. A daily `cleanupSchedule` deletes +uploads that were prepared but never used before their `expiration` (default one day). + +It is strongly recommended that you use this endpoint for API file handling rather than implementing the flow +yourself. + +### Quarantine (jail) and scanning + +If you pass a non-empty list of `FileScanner`s (for example, the ClamAV scanner from the `files-clamav` module), +uploads are first written to a jailed location (`jailFilePath`, default `upload-jail`) instead of the ready +location (`filePath`, default `uploaded`). Before the +file can be used it must be scanned and moved out of jail. The client does this by calling the `verify` (POST) +endpoint with the token; if the file is safe it is moved to the ready location and a reusable reference is returned. +Verifying up front also makes the subsequent request faster, since the scan has already happened. + +With an empty scanner list, uploads go straight to the ready location and are certified as already scanned. + +### ⚠️ Only one `UploadEarlyEndpoint` per server + +`UploadEarlyEndpoint` registers a **contextual serializer for `ServerFile`** (via its `externalSerialization`) so +that upload tokens can be decoded as `ServerFile` values. If you instantiate `UploadEarlyEndpoint` more than once +(for example, a separate instance created only for a test), the multiple instances register **conflicting +`ServerFile` serializers**. This does not fail at compile time — it surfaces at runtime as serialization errors, +typically appearing as `500 Internal Server Error` responses. + +**Instantiate `UploadEarlyEndpoint` exactly once in your server definition and reference that single instance +everywhere, including from tests.** + ## Available Backends ### Local -Simply use a local filesystem folder. +Use a local filesystem folder: ```json5 // settings.json @@ -122,9 +206,8 @@ Simply use a local filesystem folder. ```kotlin // Server.kt -object Server: ServerPathGroup(ServerPath.root) { - // Adds S3FileSystem to the possible file system loaders - init { S3FileSystem } +object Server : ServerBuilder() { + init { S3PublicFileSystem } } ``` @@ -134,20 +217,3 @@ object Server: ServerPathGroup(ServerPath.root) { "files": { "url": "s3://[user]:[password]@[bucket].[region].amazonaws.com" } } ``` - -### Azure Blob Storage - -```kotlin -// Server.kt -object Server: ServerPathGroup(ServerPath.root) { - // Adds AzureFileSystem to the possible file system loaders - init { AzureFileSystem } -} -``` - -```json5 -// settings.json -{ - "files": { "url": "azbs://key@account/container" } -} -``` \ No newline at end of file diff --git a/docs/media.md b/docs/media.md index d9631b9df..071856012 100644 --- a/docs/media.md +++ b/docs/media.md @@ -44,10 +44,16 @@ Use this in your models instead of plain `ServerFile` when you want automatic pr data class Post( override val _id: Uuid = Uuid.random(), val title: String, - val image: ServerFileWithMetadata // Instead of ServerFile + val image: ServerFileWithMetadata? = null // Instead of ServerFile ) : HasId ``` +The processing helpers below address the field through a +`DataClassPath`, so a nullable field is the path of least +resistance. For a non-nullable field, use `interceptImagesForProcessingNotNull` +instead of `interceptImagesForProcessing`; `processImagesInBackground` has no +non-nullable variant and requires a nullable field. + ### MediaPreviewOptions Configure how previews are generated using `MediaPreviewOptions`: @@ -91,20 +97,31 @@ Process images immediately when records are created or updated. This blocks the **Example:** ```kotlin -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) - val posts = database - .table() - .interceptImagesForProcessing( - MediaPreviewOptions(sizeInPixels = 200), - MediaPreviewOptions(sizeInPixels = 800), - makePath = { it.path { it.image } } - ) + // `signals` wraps the table on every access, so any write that goes through + // this ModelInfo generates previews — no endpoint has to remember to. + val postInfo = database.modelInfo?, Post, Uuid>( + auth = noAuth, + tableName = "Post", + signals = { table -> + table.interceptImagesForProcessing( + MediaPreviewOptions(sizeInPixels = 200), + MediaPreviewOptions(sizeInPixels = 800), + makePath = { it.image }, + ) + }, + permissions = { ModelPermissions.allowAll() }, + ) + + val posts = path.path("posts") include ModelRestEndpoints(postInfo) } ``` +The interceptor must run against a live `Table`, which only exists inside a +`ServerRuntime` — that is exactly what `signals` provides. + ### 2. Background Processing (Tasks) Process images asynchronously after the record is saved. This provides faster API responses but previews may not be @@ -119,18 +136,23 @@ immediately available. **Example:** ```kotlin -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) - // Define the task - val processPostImages = path.path("task").path("process-post-images").task bind + val postInfo = database.modelInfo?, Post, Uuid>( + auth = noAuth, + tableName = "Post", + permissions = { ModelPermissions.allowAll() }, + ) + + // Define the task; it listens for changes on postInfo's table + val processPostImages = path.path("task").path("process-post-images") bind processImagesInBackground( - info = typedEndpoints.posts, + info = postInfo, MediaPreviewOptions(sizeInPixels = 200), MediaPreviewOptions(sizeInPixels = 800), timeout = 5.minutes, - makePath = { it.path { it.image } } + makePath = { it.image }, ) } ``` @@ -170,33 +192,39 @@ Here's a complete example integrating media processing into an API: data class Product( override val _id: Uuid = Uuid.random(), val name: String, - val photo: ServerFileWithMetadata, + val photo: ServerFileWithMetadata? = null, val createdAt: Instant = Clock.System.now() ) : HasId -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) val files = setting("files", Files.Settings()) - // Use interceptor for immediate availability - val products = database - .table() - .interceptImagesForProcessing( - MediaPreviewOptions(sizeInPixels = 100), // Thumbnail - MediaPreviewOptions(sizeInPixels = 400), // Medium - MediaPreviewOptions(sizeInPixels = 1200), // Large - makePath = { it.path { it.photo } } - ) + val productInfo = database.modelInfo?, Product, Uuid>( + auth = noAuth, + tableName = "Product", + // Use the interceptor for immediate availability + signals = { table -> + table.interceptImagesForProcessing( + MediaPreviewOptions(sizeInPixels = 100), // Thumbnail + MediaPreviewOptions(sizeInPixels = 400), // Medium + MediaPreviewOptions(sizeInPixels = 1200), // Large + makePath = { it.photo }, + ) + }, + permissions = { ModelPermissions.allowAll() }, + ) + + val products = path.path("products") include ModelRestEndpoints(productInfo) - // Or use background task for better performance - val processProductImages = path.path("task").path("process-images").task bind + // Or use a background task for better response times — pick one, not both + val processProductImages = path.path("task").path("process-images") bind processImagesInBackground( info = productInfo, MediaPreviewOptions(sizeInPixels = 100), MediaPreviewOptions(sizeInPixels = 400), MediaPreviewOptions(sizeInPixels = 1200), - makePath = { it.path { it.photo } } + makePath = { it.photo }, ) } ``` @@ -247,12 +275,12 @@ The processing functions only process image files - other file types are returne ```kotlin // This is safe even if some uploads are PDFs or other non-image files -val documents = database - .table() - .interceptImagesForProcessing( +signals = { table -> + table.interceptImagesForProcessing( MediaPreviewOptions(sizeInPixels = 200), - makePath = { it.path { it.attachment } } + makePath = { it.attachment }, ) +} ``` ## Limitations diff --git a/docs/migration-v4-to-v5.md b/docs/migration-v4-to-v5.md new file mode 100644 index 000000000..a3153f668 --- /dev/null +++ b/docs/migration-v4-to-v5.md @@ -0,0 +1,200 @@ +# Migrating from v4 to v5 + +Last updated July 2025 (`version-5`) + +Lightning Server 5 is a substantial reorganization of version 4. The biggest change is that the service +abstractions (database, cache, files, email, notifications) were extracted into a separate +`com.lightningkite.services.*` library, and the server itself was split into a **definition** phase and a +**runtime** phase. This guide covers the server-side changes. + +!!! note + The authoritative, continually-updated migration reference — including the KiteUI 7 client-side changes — is + the `ls5-kui7-migration` skill. This page summarizes the server portions; consult the skill for the full + checklist and for client/app migration. + +## Overview of major changes + +- **Service abstractions extracted.** Database, files, cache, email, and notifications now live under + `com.lightningkite.services.*` instead of `com.lightningkite.lightningserver.*`. +- **Definition vs. runtime split.** A server is now declared as a `ServerBuilder` (the definition). Services and + settings resolve to `Runtime` values that are only realized inside a `ServerRuntime` context. +- **Context parameters.** Kotlin context parameters are used heavily — most runtime code carries + `context(runtime: ServerRuntime)`. This requires the `-Xcontext-parameters` compiler flag. +- **Standard-library types.** `com.lightningkite.UUID` → `kotlin.uuid.Uuid`, and `kotlinx.datetime.Instant` → + `kotlin.time.Instant`. +- **Module split.** The single `...Shared` client artifact was split into `core-shared`, `typed-shared`, + `sessions-shared`, `files-shared`, and `media-shared`. + +## Dependencies + +The old single server and single shared dependencies become several targeted modules, and database/files/etc. +come from the service-abstractions library. Representative server dependencies: + +```kotlin +dependencies { + api(libs.comLightningKite.services.database) + api(libs.comLightningKite.services.database.jsonfile) + api(libs.comLightningKite.lightningServer.core) + api(libs.comLightningKite.lightningServer.typed) + api(libs.comLightningKite.lightningServer.files) + api(libs.comLightningKite.lightningServer.sessions) + ksp(libs.comLightningKite.services.database.processor) +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + freeCompilerArgs.add("-Xcontext-parameters") + } +} +``` + +The database KSP processor moved from `...lightningserver...Processor` to +`com.lightningkite.services:database-processor`. See the migration skill for the full artifact-name mapping. + +## Imports + +The most common import moves: + +```kotlin +// Standard-library types +import kotlin.uuid.Uuid // was com.lightningkite.UUID +import kotlin.time.Instant // was kotlinx.datetime.Instant +import com.lightningkite.lightningserver.runtime.now // was com.lightningkite.now + +// Database & data (was com.lightningkite.lightningdb.*) +import com.lightningkite.services.data.* +import com.lightningkite.services.database.* +import com.lightningkite.services.files.* + +// Services (were under com.lightningkite.lightningserver.*) +import com.lightningkite.services.files.ServerFile // was ...lightningserver.files.ServerFile +import com.lightningkite.services.files.PublicFileSystem +import com.lightningkite.services.database.Database +import com.lightningkite.services.cache.Cache +import com.lightningkite.services.email.Email +import com.lightningkite.services.notifications.NotificationService + +// Server builder (was com.lightningkite.lightningserver.core.ServerPathGroup) +import com.lightningkite.lightningserver.definition.builder.ServerBuilder +import com.lightningkite.lightningserver.runtime.ServerRuntime +``` + +## Server definition: `ServerPathGroup` → `ServerBuilder` + +Servers and endpoint groups are now `ServerBuilder` objects. Settings use the service `Settings()` factories, and +sub-groups are attached with `module`/`include`: + +```kotlin +// OLD +object Server : ServerPathGroup(ServerPath.root) { + val database = setting(name = "database", default = DatabaseSettings()) + val files = setting(name = "files", default = FilesSettings()) + val users = UserEndpoints(path("users")) +} + +// NEW +object Server : ServerBuilder() { + val database = setting("database", Database.Settings()) + val files = setting("files", PublicFileSystem.Settings()) + val users = path.path("users") module UserEndpoints +} +``` + +Settings changed from standalone `*Settings()` constructors to nested `Service.Settings()` factories, for example +`CacheSettings()` → `Cache.Settings()`, `FilesSettings()` → `PublicFileSystem.Settings()`, +`NotificationSettings("console")` → `NotificationService.Settings("console")`. + +## Definition vs. runtime + +In v4, calling a setting gave you the service directly. In v5 a setting is a `Runtime` in the definition, and +you resolve it inside a `ServerRuntime` context (for example within a handler, where the context is present). Code +that touches services must therefore carry `context(runtime: ServerRuntime)`: + +```kotlin +// A helper that uses the database now declares the runtime context. +context(runtime: ServerRuntime) +suspend fun countUsers(): Int = userTable().count() +``` + +This is the most pervasive source of migration compile errors: functions called from handlers, hooks, or signals +need the `context(runtime: ServerRuntime)` receiver added. + +## Endpoint registration + +Handlers and sub-groups are attached to paths with infix operators instead of constructor-with-path: + +```kotlin +// Sub-group of endpoints +val users = path.path("users") module UserEndpoints + +// A single ServerBuilder component (e.g. generated REST endpoints) +val rest = path.path("rest") include ModelRestEndpoints(info) + +// A single handler / task / topic +val root = path.get bind HttpHandler { HttpResponse.plainText("Hello") } +``` + +## Database changes + +- `.collection()` → `.table()`, and `.baseCollection()` → `.baseTable()`. +- `UUID` → `Uuid` throughout models: `HasId` → `HasId`, `UUID.random()` → `Uuid.random()`. +- Instants use `kotlin.time.Instant`; use `now()` from `com.lightningkite.lightningserver.runtime.now`. +- `ModelInfo` signals operate on tables and take context-parameterized hooks. + +```kotlin +// OLD +info.collection().insertOne(model) + +// NEW +info.table().insertOne(model) +``` + +## Typed endpoints + +Typed endpoints are declared with `ApiHttpHandler` bound to a path, taking the auth requirement as `auth` and the +logic as `implementation`: + +```kotlin +val hello = path.path("hello").get bind ApiHttpHandler( + summary = "Hello", + description = "Returns a greeting", + auth = noAuth, + implementation = { input: Unit -> "Hello" } +) +``` + +See [Typed Endpoints](typed-endpoints.md) for the full API. + +## WebSockets + +The websocket package moved from `...websocket` (singular) to `...websockets` (plural), and handlers are attached +with `bind`/`include`: + +```kotlin +// OLD +import com.lightningkite.lightningserver.websocket.MultiplexWebSocketHandler +val multiplex = path("multiplex").websocket(MultiplexWebSocketHandler(cache)) + +// NEW +import com.lightningkite.lightningserver.websockets.MultiplexWebSocketHandler +val multiplex = path.path("multiplex") bind MultiplexWebSocketHandler() +``` + +See [WebSockets](websockets.md) for the current API. + +## Common pitfalls + +- **Missing context parameter.** "Missing context receiver" compile errors mean a function that uses services + needs `context(runtime: ServerRuntime)`. +- **`collection()` unresolved.** Replace `.collection()`/`.baseCollection()` with `.table()`/`.baseTable()`. +- **Wrong `Instant` import.** Use `kotlin.time.Instant`, not `kotlinx.datetime.Instant`. +- **Duplicate `UploadEarlyEndpoint`.** Instantiate it exactly once; multiple instances register conflicting + `ServerFile` serializers and cause runtime 500s. See [File Systems](files.md). + +## Client and KiteUI migration + +This page intentionally focuses on the server. The client SDK and KiteUI 7 changes (dependency renames, the dash → +dot view-modifier syntax, theme derivations, `Api2` → `Api`, `OtpSecret` → `TotpSecret`, and more) are extensive +and are documented in full in the `ls5-kui7-migration` skill. diff --git a/docs/migration-v5.1-to-v5.2.md b/docs/migration-v5.1-to-v5.2.md new file mode 100644 index 000000000..16c1a7421 --- /dev/null +++ b/docs/migration-v5.1-to-v5.2.md @@ -0,0 +1,233 @@ +# Migrating from v5.1 to v5.2 + +Last updated July 2026 (`5.2.0`) + +Lightning Server 5.2 is a hardening and polish release on top of 5.1 — most of the diff is documentation, +tests, and internal robustness work, so there is no large-scale reorganization like the +[v4 → v5](migration-v4-to-v5.md) move. Sections 0–4 below are small, mechanical edits (one dependency bump, +two source edits, one settings-file edit). Section 5 is the one **runtime-behavior** change — pre-deploy +tasks — and whether it needs action depends on how you deploy. + +!!! tip + The first four items were exactly what it took to move a real, mid-sized application (auth, sessions, + files, AWS serverless deployment, a KiteUI web client) from 5.1 to 5.2 with no behavior change. Section 5 + is separate: it only requires action if you run `serve` without a pre-deploy step (see that section). + +## 0. Bump the service-abstractions version + +Lightning Server 5.2 depends on a newer release of the `com.lightningkite.services:*` (service-abstractions) +library, and the telemetry refactor below spans both libraries. If your build pins a `serviceAbstractions` +version of its own, it **must** be raised to the version your Lightning Server 5.2 build was compiled against +— otherwise you get a clean compile but a runtime `NoSuchMethodError` (e.g. +`SettingContext.getOpenTelemetry()`) as mismatched classes meet on the classpath. + +```toml +# gradle/libs.versions.toml +# Match whatever your Lightning Server 5.2 release depends on. 5.2.0 uses: +serviceAbstractions = "1.2.0-1-b2f7bd67" +``` + +You can confirm the exact version from the Lightning Server release's POM (the `com.lightningkite.services` +dependency versions). This is the single most important step — do it first. + +## 1. `AuthEndpoints` / `SessionManager` now require a `cache` + +`AuthEndpoints` (and its superclass `SessionManager`) gained a required `cache: Runtime` constructor +parameter, positioned immediately after `database`. It backs auth-related caching (e.g. session/proof lookup +keys and rate limiting), so it must be a **shared** cache in any multi-instance or serverless deployment — not +the in-memory `"ram"` cache — otherwise instances will not see each other's entries. + +```kotlin +// Before (5.1) +class SessionEndpoints : AuthEndpoints( + principal = UserAuth, + database = Server.database, +) { /* ... */ } + +// After (5.2) +class SessionEndpoints : AuthEndpoints( + principal = UserAuth, + database = Server.database, + cache = Server.cache, +) { /* ... */ } +``` + +Pass whatever `Runtime` setting your server already defines (`Server.cache` in the example). + +## 2. Telemetry settings: `OpenTelemetrySettings` → `TelemetryBackend.Settings` + +Telemetry configuration was unified onto the service-abstractions `TelemetryBackend` SPI. Two things changed: + +- The `telemetrySettings` global is now the **non-nullable** type + `TelemetryBackend.Settings` (was a nullable `OpenTelemetrySettings?`). +- `com.lightningkite.services.otel.OpenTelemetrySettings` is deprecated. The same URL schemes it accepted are + now registered directly on `TelemetryBackend.Settings` by the OpenTelemetry module. + +Update the import: + +```kotlin +// Before +import com.lightningkite.services.otel.OpenTelemetrySettings +// After +import com.lightningkite.services.telemetry.TelemetryBackend +``` + +Then translate the values. A configured backend becomes a `TelemetryBackend.Settings(url = ...)`, and +"telemetry off" — previously expressed as `null` — becomes the default `TelemetryBackend.Settings()`, whose +`url` defaults to `"noop"` (a no-op backend): + +```kotlin +// Before (5.1) +telemetrySettings.direct(OpenTelemetrySettings("console", batching = null)) +telemetrySettings.direct(null) // disabled + +// After (5.2) +telemetrySettings.direct(TelemetryBackend.Settings(url = "console")) +telemetrySettings.direct(TelemetryBackend.Settings()) // disabled (url = "noop") +``` + +The URL schemes `console`, `log`, `dev`, `debounced-dev`, and `otlp-grpc` / `otlp-http` / `otlp-https` are +registered when the OpenTelemetry module (`com.lightningkite.services:otel-jvm`) is on the classpath. Without +it, only the built-in `noop` scheme is available. Helper extensions such as +`telemetrySettings.otelGrafanaCloud(...)` are unaffected and continue to work. + +## 3. Update the `telemetry` entry in existing `settings.json` files + +Because `telemetrySettings` is no longer nullable (see above), the serialized form changed too. Any existing +`settings.json` (or `settings.testing.json`, etc.) that carries `"telemetry": null` — the 5.1 way of saying +"disabled" — will now fail to **load** at startup with a JSON parse error, since the deserializer expects a +`TelemetryBackend.Settings` object rather than `null`. + +```jsonc +// Before (5.1) +"telemetry": null, + +// After (5.2) — object form; url "noop" means disabled +"telemetry": { "url": "noop" }, +``` + +A configured backend uses the same URL schemes as above, e.g. `"telemetry": { "url": "console" }`. This only +affects settings files you carry across the upgrade; a freshly generated settings file already uses the new +shape. + +## 4. AWS serverless deployments must declare an `applicationVpc` (AWS deployers only) + +If you deploy to AWS with `TerraformAwsServerlessDomainBuilder`, that builder now requires an `applicationVpc` +override — without one the deployment object fails to compile ("is not abstract and does not implement abstract +member: `val applicationVpc: AwsVpc`"). Deployments that don't run inside a VPC use `AwsVpc.None`: + +```kotlin +object LkEnv : TerraformAwsServerlessDomainBuilder(Server) { + // ...existing overrides (region, handler, storageBucket, ...) + override val applicationVpc: AwsVpc = AwsVpc.None // add this; import com.lightningkite.services.terraform.AwsVpc +} +``` + +This is compile-time only and unrelated to local runs, but every AWS deployment object in your project needs it. + +## 5. Pre-deploy tasks: `serve` no longer prepares your database + +This is the one **runtime-behavior** change to be aware of, and it matters most if you deploy with your +own pipeline rather than the Lightning Server AWS builders. + +### What changed + +Lightning Server gained a new concept, **`PreDeployTask`**, that sits alongside `StartupTask`: + +- A **`StartupTask`** runs in *every server instance* as it boots, on the request-serving path. +- A **`PreDeployTask`** runs *once per deploy*, in a dedicated `predeploy` invocation, **before the new + version starts serving** and concurrently with the still-live previous version. If it fails, the deploy + is aborted and the old version keeps serving. + +Database table/index reconciliation — the work `ModelInfo` used to register as a `StartupTask` (its +`prepare` step) — is now a **`PreDeployTask`**. This moves migration work off every cold start and +scale-out (a real latency win, especially on Lambda) and guarantees it completes before new code serves. + +!!! warning "The breaking part" + Because table preparation moved out of startup, **`bin/server serve` no longer prepares your + database.** Anywhere you previously relied on a plain `serve` to create tables/indexes — local + development against a real database, or a custom single-process production deploy — must now run the + pre-deploy step. (Unit tests are unaffected: the test harness runs neither startup nor pre-deploy + tasks automatically.) + +### What you need to do + +**1. Expose a `predeploy` command** in your application's CLI, mirroring your existing `serve` command. +Each engine (Ktor/Netty/JDK) exposes `runPreDeploy()`, which loads settings, runs all pre-deploy tasks +fail-fast, disconnects services, and returns (non-zero exit on failure): + +```kotlin +private fun predeploy() { + val built = Server.build() + KtorEngine(built).apply { + settings.loadFromFile(KFile("settings.json"), internalSerializersModule) + runPreDeploy() // runs all PreDeployTasks once, then returns + } +} + +fun main(vararg args: String) { + cli(arguments = args, available = listOf(::serve, ::predeploy, /* ... */)) +} +``` + +**2. For local development**, add a convenience `dev` command that does prepare-then-serve in one process +(so a single command still "just works"): + +```kotlin +private fun dev() { + val built = Server.build() + KtorEngine(built).apply { + settings.loadFromFile(KFile("settings.json"), internalSerializersModule) + settings.ready() + runPreDeployTasksBlocking() // prepare, leaving services connected + start(Netty) // then serve + } +} +``` + +**3. In production, run `predeploy` before cutover.** If you deploy with the Lightning Server AWS +builders, **this is already wired for you** — the EC2 (single and scaling) and serverless deployments now +run the pre-deploy step before switching traffic to the new version, and abort the deploy if it fails. If +you roll your own pipeline, invoke `bin/server predeploy` and gate the cutover on its success. + +### Migrating your own `StartupTask`s + +Audit each `StartupTask` you defined and decide where it belongs: + +- **Move to `PreDeployTask`** — anything that mutates shared state the new code depends on: schema/index + creation, data backfills, one-time setup, and prep that must be done before new code serves. +- **Keep as `StartupTask`** — genuinely per-instance work: in-memory cache warming, establishing + instance-local state, cheap config validation. + +`PreDeployTask` uses lazily-supplied dependencies (so you can reference tasks declared later or in other +modules without initialization-order surprises), and **every pre-deploy task runs on every deploy** — the +framework tracks no history, so make them idempotent/convergent. For genuinely "run exactly once ever" +work, guard it with a database marker (`doOnce`) *inside* a `PreDeployTask`; because pre-deploy runs once +per deploy off the serving path, that check is cheap and uncontended: + +```kotlin +// Before (5.1): a one-time seed as a per-instance startup task +val setupAdmins = path.path("setup-admins") bind startupOnce(database) { + userInfo.table().insertOne(User(email = "admin@example.com", isSuperUser = true)) +} + +// After (5.2): a pre-deploy task; doOnce keeps it once-ever +val setupAdmins = path.path("setup-admins") bind PreDeployTask { + doOnce("setup-admins", database) { + userInfo.table().insertOne(User(email = "admin@example.com", isSuperUser = true)) + } +} +``` + +!!! note "Startup tasks now fail fast" + A related fix: a failing `StartupTask` now aborts startup. Previously an exception from a startup task + with no dependents was silently swallowed and the server started anyway. If any of your startup tasks + threw and you relied on that being ignored, make the error handling explicit. + +## Not source-breaking, but worth knowing + +- **Engine reliability settings.** The JDK, Netty, and Ktor engines share a new `EngineReliabilitySettings` + (graceful shutdown, bounded thread pools, request size/time limits). It has sensible defaults, so no code + change is required; review the defaults if you tune server capacity. +- **Docs.** The endpoint, database, authentication, files, and websockets guides were substantially rewritten + for 5.x — worth a re-read if you onboarded on an earlier prerelease. diff --git a/docs/sessions.md b/docs/sessions.md index 84c522679..511e276e0 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -127,11 +127,9 @@ object Server : ServerBuilder() { } ) - val passwordProof = PasswordProofEndpoints( - table = database.table(), - getSubjectId = { it.email }, - hash = { it.secureHash() } - ) + // PasswordProofEndpoints registers its own "PasswordSecret" table and hashes + // passwords on write; you only hand it the database and cache. + val passwordProof = PasswordProofEndpoints(database, cache) // Include in your server init { diff --git a/docs/typed-endpoints.md b/docs/typed-endpoints.md index 4544bd4b1..5b75c065a 100644 --- a/docs/typed-endpoints.md +++ b/docs/typed-endpoints.md @@ -164,6 +164,7 @@ object MyApi : ServerBuilder() { val postsInfo = database.modelInfo( auth = authOptions(), + tableName = "Post", permissions = { // TODO: WARNING! This exposes all create, edit, delete, and read capabilities! // We probably want something more restrictive, even for a demonstration. diff --git a/docs/websockets.md b/docs/websockets.md index f73902fbc..743383088 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,5 +1,155 @@ -# Websockets +# WebSockets -**OUT OF DATE** +Last updated July 2025 (`version-5`) -TODO \ No newline at end of file +Lightning Server supports WebSockets for real-time, two-way communication. The core primitives live in +`com.lightningkite.lightningserver.websockets`; the `typed` module adds type-safe WebSockets that participate in +SDK generation. + +A key design goal is that the same handler runs identically whether the server is a single local process or a +fleet of AWS Lambda instances behind API Gateway. To make that work, connection state and cross-connection +delivery are modeled explicitly (serializable storage and pub/sub topics) rather than kept in local memory. + +## Declaring a WebSocket endpoint + +Bind a `WebSocketHandler` to a path with `bind`. The handler is built from lifecycle callbacks. The value returned +by `willConnect` becomes this connection's `STORAGE` (its per-connection state); its type is inferred. + +```kotlin +import com.lightningkite.lightningserver.websockets.* +import kotlin.uuid.Uuid + +object ChatEndpoints : ServerBuilder() { + val echo = path.path("ws").path("echo") bind WebSocketHandler( + // Runs before the connection is accepted. Returns the initial STORAGE. + willConnect = { request -> "echo-${Uuid.random()}" }, + // Runs once the connection is open. `this` is the connection. + didConnect = { send("Echo server ready") }, + // Runs for each inbound frame from the client. + messageFromClient = { frame -> send("Echo: ${frame.text}") }, + // Runs when the connection closes. + disconnect = { reason -> println("Closed: $currentState ($reason)") }, + ) +} +``` + +### Connection lifecycle + +The callbacks receive different contexts: + +- `willConnect: suspend ServerRuntime.(request) -> STORAGE` — runs with a `ServerRuntime`. Return the initial + state. Throwing here rejects the connection. +- `didConnect`, `messageFromClient`, `disconnect` — run with the `WebSocketConnection` as receiver, which extends + `ServerRuntime` and adds WebSocket-specific members. + +Inside those callbacks you have access to: + +- `currentState: STORAGE` — the current per-connection state. +- `send(...)` — send a frame to the client. Overloads accept `String`, `ByteArray`, or a `WebSocketFrame`. +- `subscribe(topic)` / `unsubscribe(topic)` — join or leave a pub/sub topic. +- `updateStateImmediately { }` / `queueStateUpdate { }` — atomically update `STORAGE`. +- `close(reason)` — close with a `WebSocketClose` code (e.g. `WebSocketClose.NORMAL`). + +Inbound frames are `WebSocketFrame` (`WebSocketFrame.Text` or `WebSocketFrame.Binary`). Use `frame.text` for the +string form. + +## Topics and pub/sub delivery + +Because a connection may live on a different instance than the code that wants to message it, server-to-client +broadcasts go through **topics**. A topic is a named, typed channel that connections subscribe to; publishing to it +delivers the message to every subscribed connection, across all instances, via the configured pub/sub service. + +Declare a topic on a path with `.topic(serializer)`: + +```kotlin +import com.lightningkite.lightningserver.runtime.send // topic.send(...) extension +import kotlinx.serialization.builtins.serializer + +object ChatEndpoints : ServerBuilder() { + val chatTopic = path.path("ws").path("chat-topic").topic(ChatMessage.serializer()) + + val chatSocket = path.path("ws").path("chat") bind WebSocketHandler( + willConnect = { Uuid.random().toString() }, + didConnect = { + subscribe(chatTopic) // start receiving topic messages + send("Welcome, session $currentState") + }, + messageFromClient = { frame -> + val incoming = Json.decodeFromString(ChatMessage.serializer(), frame.text) + chatTopic.send(incoming) // broadcast to all subscribers + }, + // Handle messages that arrive from subscribed topics. + topicHandlers = { + chatTopic bind { message -> + send(Json.encodeToString(ChatMessage.serializer(), message.value)) + } + }, + ) +} +``` + +You can also publish to a topic from anywhere that has a `ServerRuntime` context — for example, from an HTTP +handler — using the `send` extension (`import com.lightningkite.lightningserver.runtime.send`): + +```kotlin +val announce = path.path("announce").arg("text").get bind HttpHandler { request -> + chatTopic.send(ChatMessage(content = request.path.arg1)) + HttpResponse.plainText("sent") +} +``` + +Topics may carry path parameters, so subscriptions can be scoped to a specific resource. A single-argument topic is +published with `topic.send(path1 = "user123", value = ...)` and subscribed to with `subscribe(topic, "user123")`. + +## Multiplexed connections + +`MultiplexWebSocketHandler` lets a client run many logical channels over a single physical WebSocket connection, +which is useful when a client wants several simultaneous subscriptions without opening many sockets. + +```kotlin +val multiplex = path.path("ws").path("multiplex") bind MultiplexWebSocketHandler() +``` + +`QueryParamWebSocketHandler` is a related helper that selects the underlying handler based on a query parameter. + +## Typed WebSockets + +The `typed` module provides `ApiWebsocketHandler`, which adds typed `INPUT`/`OUTPUT` messages, authentication, and +SDK generation. Instead of raw frames, your callbacks receive already-deserialized `INPUT` values and `send` takes +an `OUTPUT` value; content negotiation (JSON/CBOR) is handled for you. + +```kotlin +import com.lightningkite.lightningserver.typed.ApiWebsocketHandler +import com.lightningkite.lightningserver.auth.noAuth + +val liveFeed = path.path("ws").path("feed") bind ApiWebsocketHandler<_, Unit, User?, FeedRequest, FeedUpdate>( + summary = "Live Feed", + description = "Streams feed updates to the client.", + auth = noAuth, + willConnectType = { access -> Unit }, + messageFromClientType = { request: FeedRequest -> + send(FeedUpdate(/* ... */)) // send takes a typed OUTPUT + }, +) +``` + +Inside the typed callbacks, `auth()` resolves the connection's authenticated principal, and `send`, `subscribe`, +and the state helpers behave as they do for raw handlers. + +`ModelRestUpdatesWebsocket` (and the combined `ModelRestEndpoints(info) + ModelRestUpdatesWebsocket(info)`) build +on this to push live database changes to clients — see the model REST documentation. + +## Local vs. AWS execution models + +The lifecycle above is identical across engines, but the machinery underneath differs: + +- **Local engines (Ktor, Netty, JDK).** Everything runs in one process. Handlers that implement + `DirectExecutableWebSocketHandler` are driven directly, bypassing pub/sub overhead. Per-connection state and + subscriptions are kept in-process. +- **AWS (API Gateway + Lambda).** Each frame may be handled by a different Lambda instance, so nothing can rely on + local memory. The `STORAGE` object is serialized and stored (in DynamoDB), subscriptions are tracked there too, + and outbound messages are routed either directly to the API Gateway connection (via the connection's + `engineSocketId`) or through the pub/sub service. This is why `STORAGE` must be `@Serializable` and why + cross-connection messaging goes through topics rather than direct references. + +Writing to the lifecycle/topic API keeps your code portable between the two. diff --git a/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/engine/awsserverless/AwsAdapter.kt b/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/engine/awsserverless/AwsAdapter.kt index 86f60625c..da528c0eb 100644 --- a/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/engine/awsserverless/AwsAdapter.kt +++ b/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/engine/awsserverless/AwsAdapter.kt @@ -244,6 +244,16 @@ public open class AwsAdapter(server: ServerDefinition) : ServerRuntimeBase(serve schedules.handleSchedule(parsed) } + asJson.containsKey("predeploy") -> { + try { + runPreDeployTasks() + APIGatewayV2HTTPResponse(statusCode = 200, body = "predeploy-ok") + } catch (e: Exception) { + logger.error(e) { "Pre-deploy tasks failed" } + APIGatewayV2HTTPResponse(statusCode = 500, body = "predeploy-failed: ${e.message}") + } + } + asJson.containsKey("topic") -> { val parsed: AwsAdapterWs.WebSocketPublish = internalSerialization.json.decodeFromJsonElement(asJson) diff --git a/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/terraform/awsserverless/TerraformAwsServerlessBuilder.kt b/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/terraform/awsserverless/TerraformAwsServerlessBuilder.kt index 37e18a3bd..37ba87b12 100644 --- a/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/terraform/awsserverless/TerraformAwsServerlessBuilder.kt +++ b/engine-aws-serverless/src/main/kotlin/com/lightningkite/lightningserver/terraform/awsserverless/TerraformAwsServerlessBuilder.kt @@ -654,11 +654,34 @@ public abstract class TerraformAwsServerlessBuilder( "depends_on" - listOf("aws_s3_object.app_storage") } + // Pre-deploy gate: invoke the freshly-published function version with a {"predeploy":true} + // event and only proceed to the alias cutover if it reports "predeploy-ok". The payload is + // supplied via a file to avoid cross-platform shell quoting. Triggered on every code change. + // Note: with snapStart the new version is isolated so this truly gates cutover; without + // snapStart the alias tracks $LATEST, which is live as soon as the function updates. + "resource.local_file.predeploy_payload" { + "content" - "{\"predeploy\":true}" + "filename" - $$"${path.module}/build/predeploy-payload.json" + } + "resource.null_resource.predeploy" { + "triggers" { + "code" - expression("aws_lambda_function.main.source_code_hash") + } + "depends_on" - listOf("aws_lambda_function.main", "local_file.predeploy_payload") + "provisioner.local-exec" { + "command" - expression( + $$"""local.is_windows ? "aws lambda invoke --function-name ${aws_lambda_function.main.function_name} --qualifier ${aws_lambda_function.main.version} --payload file://${path.module}/build/predeploy-payload.json --region $${emitter.applicationRegion} ${path.module}/build/predeploy-out.json | Out-Null; if(-not(Select-String -Path ${path.module}/build/predeploy-out.json -Pattern predeploy-ok -Quiet)){exit 1}" : "aws lambda invoke --function-name ${aws_lambda_function.main.function_name} --qualifier ${aws_lambda_function.main.version} --payload file://${path.module}/build/predeploy-payload.json --region $${emitter.applicationRegion} ${path.module}/build/predeploy-out.json > /dev/null && grep -q predeploy-ok ${path.module}/build/predeploy-out.json""" + "\"" + ) + "interpreter" - expression("local.is_windows ? [\"PowerShell\", \"-Command\"] : []") + } + } "resource.aws_lambda_alias.main" { "name" - "prod" "description" - "The current production version of the lambda." "function_name" - expression("aws_lambda_function.main.arn") "function_version" - (if (snapStart) expression("aws_lambda_function.main.version") else "\$LATEST") + // Cut over to the new version only after pre-deploy tasks succeed. + "depends_on" - listOf("null_resource.predeploy") } "resource.aws_cloudwatch_log_group.main" { "name" - "${emitter.projectPrefix}-main-log" diff --git a/engine-local/src/main/kotlin/com/lightningkite/lightningserver/engine/local/LocalEngine.kt b/engine-local/src/main/kotlin/com/lightningkite/lightningserver/engine/local/LocalEngine.kt index cc2ecec45..fb5d60a40 100644 --- a/engine-local/src/main/kotlin/com/lightningkite/lightningserver/engine/local/LocalEngine.kt +++ b/engine-local/src/main/kotlin/com/lightningkite/lightningserver/engine/local/LocalEngine.kt @@ -347,6 +347,15 @@ public abstract class LocalEngine(server: ServerDefinition) : ServerRuntimeBase( * deploy pipeline can abort the cutover. Services are disconnected afterwards so the process can * exit cleanly. */ + /** + * Runs all pre-deploy tasks and returns, leaving services connected. Intended to be called just + * before [start] in a combined "prepare then serve" dev command, so a single local process + * reconciles the database and then serves. Settings must already be ready. + */ + public fun runPreDeployTasksBlocking() { + runBlocking { runPreDeployTasks() } + } + public fun runPreDeploy() { settings.ready() try { diff --git a/files/src/main/kotlin/com/lightningkite/lightningserver/files/UploadEarlyEndpoints.kt b/files/src/main/kotlin/com/lightningkite/lightningserver/files/UploadEarlyEndpoints.kt index d8b4b6994..12cc9f5c6 100644 --- a/files/src/main/kotlin/com/lightningkite/lightningserver/files/UploadEarlyEndpoints.kt +++ b/files/src/main/kotlin/com/lightningkite/lightningserver/files/UploadEarlyEndpoints.kt @@ -11,6 +11,7 @@ import com.lightningkite.lightningserver.pathing.PathSpec0 import com.lightningkite.lightningserver.runtime.now import com.lightningkite.lightningserver.runtime.serverRuntime import com.lightningkite.lightningserver.typed.ApiHttpHandler +import com.lightningkite.lightningserver.typed.registerTable import com.lightningkite.lightningserver.typed.sdk.* import com.lightningkite.services.database.* import com.lightningkite.services.files.* @@ -46,6 +47,9 @@ public class UploadEarlyEndpoint( sdkSettings.clientInterface = ClientUploadEarlyEndpoints::class.info() } + // Defines the table, registers it, and creates its once-per-deploy prepare task. + private val uploadForNextRequestTable = database.registerTable("UploadForNextRequest") + /** * Contextual serializer used for ServerFile values that integrates with the configured PublicFileSystem * and scanners to produce signed URLs, enforce jail/ready flows, and clean up single-use records. @@ -59,7 +63,7 @@ public class UploadEarlyEndpoint( fileSystems = listOf(files()), onUse = { fileObject -> runBlocking { - database().table() + uploadForNextRequestTable() .deleteManyIgnoringOld(condition { it.file eq ServerFile(fileObject.url) }) } }, @@ -95,7 +99,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(newFile.url) ) - database().table().insertOne(newItem) + uploadForNextRequestTable().insertOne(newItem) UploadInformation( uploadUrl = newFile.uploadUrl(expiration), futureCallToken = serializer().certifyAlreadyScannedForUse(key, expiration) @@ -106,7 +110,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(newFile.url) ) - database().table().insertOne(newItem) + uploadForNextRequestTable().insertOne(newItem) UploadInformation( uploadUrl = newFile.uploadUrl(expiration), futureCallToken = serializer().certifyForUse(key, expiration) @@ -136,7 +140,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(safe.url) ) - database().table().insertOne(newItem) + uploadForNextRequestTable().insertOne(newItem) url } @@ -146,7 +150,7 @@ public class UploadEarlyEndpoint( * Daily cleanup of expired uploads. Removes database entries and attempts to delete the associated file. */ public val cleanupSchedule: ScheduledTask = path.path("cleanupUploads") bind ScheduledTask(frequency = 1.days) { - database().table().deleteMany(condition { it.expires lt now() }).forEach { + uploadForNextRequestTable().deleteMany(condition { it.expires lt now() }).forEach { try { files().parseInternalUrl(it.file.location)!!.delete() } catch (e: Exception) { diff --git a/files/src/test/kotlin/com/lightningkite/lightningserver/files/UploadEarlySdkTests.kt b/files/src/test/kotlin/com/lightningkite/lightningserver/files/UploadEarlySdkTests.kt index 58774fbac..c6d1200cf 100644 --- a/files/src/test/kotlin/com/lightningkite/lightningserver/files/UploadEarlySdkTests.kt +++ b/files/src/test/kotlin/com/lightningkite/lightningserver/files/UploadEarlySdkTests.kt @@ -37,6 +37,7 @@ class UploadEarlySdkTests { private object Module : ServerBuilder() { val info = Server.database.modelInfo( + tableName = "Model", auth = noAuth, permissions = { ModelPermissions.allowAll() } ) diff --git a/media/src/main/kotlin/com/lightningkite/lightningserver/media/index.md b/media/src/main/kotlin/com/lightningkite/lightningserver/media/index.md index 0ffd0c613..3cf9ae413 100644 --- a/media/src/main/kotlin/com/lightningkite/lightningserver/media/index.md +++ b/media/src/main/kotlin/com/lightningkite/lightningserver/media/index.md @@ -40,17 +40,25 @@ that meet or exceed requirements. ## Usage Example ```kotlin -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) - val posts = database - .table() - .interceptImagesForProcessing( - MediaPreviewOptions(sizeInPixels = 200), - MediaPreviewOptions(sizeInPixels = 800), - makePath = { it.path { it.image } } - ) + // The interceptor needs a live Table, which only exists inside a ServerRuntime; + // modelInfo's `signals` hook is where that wrapping belongs. + val postInfo = database.modelInfo?, Post, Uuid>( + auth = noAuth, + tableName = "Post", + signals = { table -> + table.interceptImagesForProcessing( + MediaPreviewOptions(sizeInPixels = 200), + MediaPreviewOptions(sizeInPixels = 800), + makePath = { it.image }, + ) + }, + permissions = { ModelPermissions.allowAll() }, + ) + + val posts = path.path("posts") include ModelRestEndpoints(postInfo) } ``` diff --git a/notifications/src/main/kotlin/com/lightningkite/lightningserver/notifications/NotificationBulkDispatcher.kt b/notifications/src/main/kotlin/com/lightningkite/lightningserver/notifications/NotificationBulkDispatcher.kt index 386685c54..859b9d23a 100644 --- a/notifications/src/main/kotlin/com/lightningkite/lightningserver/notifications/NotificationBulkDispatcher.kt +++ b/notifications/src/main/kotlin/com/lightningkite/lightningserver/notifications/NotificationBulkDispatcher.kt @@ -408,6 +408,7 @@ public abstract class NotificationBulkDispatcher, UID : Compar } private val lastRunInfo = database.explicitModelInfo( + tableName = "RunInstant", auth = noAuth, serializer = RunInstant.serializer(), idSerializer = String.serializer(), diff --git a/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/SyntaxTest.kt b/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/SyntaxTest.kt index 1e83f6916..4feb040bb 100644 --- a/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/SyntaxTest.kt +++ b/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/SyntaxTest.kt @@ -41,6 +41,7 @@ class SyntaxTest { val userInfo = database.modelInfo( User.require(), + tableName = "User", permissions = { ModelPermissions.allowAll() }, ) @@ -62,6 +63,7 @@ class SyntaxTest { inline fun , reified ID : Comparable> Runtime.testModelInfo() = modelInfo( User.require(), + tableName = T::class.simpleName!!, permissions = { ModelPermissions.allowAll() } ) } @@ -132,6 +134,7 @@ class SyntaxTest { private object ModelEndpoints : ServerBuilder() { val info: ModelInfo = Server.database.modelInfo( auth = User.require(), + tableName = "Model", permissions = { ModelPermissions.allowAll() }, signals = { table -> table diff --git a/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/TestHelper.kt b/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/TestHelper.kt index 4e0523d5a..f0f0dc42c 100644 --- a/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/TestHelper.kt +++ b/notifications/src/test/kotlin/com/lightningkite/lightningserver/notifications/TestHelper.kt @@ -56,6 +56,7 @@ context(builder: ServerBuilder) inline fun , reified ID : Comparable> Runtime.testModelInfo(): ModelInfo = modelInfo( TestUser.require(), + tableName = T::class.simpleName!!, permissions = { ModelPermissions.allowAll() } ) diff --git a/plans/all-api-suggestions.md b/plans/all-api-suggestions.md index e0e002fcf..c8a318f0f 100644 --- a/plans/all-api-suggestions.md +++ b/plans/all-api-suggestions.md @@ -69,7 +69,7 @@ fun isExpired(): Boolean = expiration?.let { it < server.clock.now() } ?: false - [ ] **`PrincipalType.kt:191`** The fetchByProperty method could be more efficient with an index-based lookup system. Consider adding a registration mechanism for indexed properties:```kotlin val indices = mapOf( - "email" to { email: String -> database().table().find { it.email eq email }.first() } + "email" to { email: String -> database().table(userTable).find { it.email eq email }.first() } ) ``` diff --git a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthClientEndpoints.kt b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthClientEndpoints.kt index cac47ea78..169fcc518 100644 --- a/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthClientEndpoints.kt +++ b/sessions-oauth/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/oauth/OauthClientEndpoints.kt @@ -21,6 +21,7 @@ public class OauthClientEndpoints( public val modelInfo: ModelInfo?, OauthClient, String> = database.modelInfo( auth = maintainPermissions or noAuth, + tableName = "OauthClient", permissions = { val isRoot = maintainPermissions.accepts(authOrNull) ModelPermissions( diff --git a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpoints.kt index dd02b5a74..32affdded 100644 --- a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpoints.kt +++ b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpoints.kt @@ -68,6 +68,7 @@ public class BackupCodeEndpoints( public val modelInfo: ModelInfo?, BackupCodeSecret, Uuid> = database.modelInfo( auth = noAuth, + tableName = "BackupCodeSecret", permissions = { ModelPermissions(all = Condition.Never) } ) @@ -170,14 +171,19 @@ public class BackupCodeEndpoints( ), successCode = HttpStatus.OK, implementation = { input: IdentificationAndPassword -> - cache().constrainAttemptRate( - cacheKey = "backup-code-count-${input.property}-${input.value}" - ) { - val subject = input.type + val subject = input.type - val handler = serverRuntime.server.principalTypes.values.find { it.name == subject } - ?: throw IllegalArgumentException("No subject $subject recognized") + val handler = serverRuntime.server.principalTypes.values.find { it.name == subject } + ?: throw IllegalArgumentException("No subject $subject recognized") + // Normalize BEFORE building the rate-limit key: the key must be derived from the canonical + // identifier so that case/whitespace variants of the same account share one bucket. Keying on + // the raw value would let an attacker dodge the limiter (and its exponential backoff) simply + // by varying case or whitespace. + val normalizedValue = handler.normalizePropertyValue(input.property, input.value) + cache().constrainAttemptRate( + cacheKey = "backup-code-count-${input.property}-${normalizedValue}" + ) { val subjectId = handler.fetchUserIdString(input.property, input.value) ?: throw BadRequestException("Invalid Backup Code") diff --git a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/KnownDeviceProofEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/KnownDeviceProofEndpoints.kt index 9c5813d0e..32bec07fc 100644 --- a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/KnownDeviceProofEndpoints.kt +++ b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/KnownDeviceProofEndpoints.kt @@ -57,6 +57,7 @@ public class KnownDeviceProofEndpoints( public val modelInfo: ModelInfo, KnownDeviceSecret, Uuid> = database.modelInfo( auth = proofMethodAuth or AuthRequirement.IsAdmin, + tableName = "KnownDeviceSecret", signals = { it.interceptCreate { it.copy(hash = it.hash.fastHash(), expiresAt = now() + expires()) diff --git a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpoints.kt index 5a97a91cc..577ad0af3 100644 --- a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpoints.kt +++ b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpoints.kt @@ -55,6 +55,7 @@ public class PasswordProofEndpoints( public val modelInfo: ModelInfo, PasswordSecret, Uuid> = database.modelInfo( + tableName = "PasswordSecret", auth = proofMethodAuth or AuthRequirement.IsAdmin, signals = { col -> col.interceptCreate { @@ -165,11 +166,15 @@ public class PasswordProofEndpoints( successCode = HttpStatus.OK, implementation = { input: IdentificationAndPassword -> val now = now() - cache().constrainAttemptRate("password-${input.property}-${input.value}") { - val subject = input.type - val handler = serverRuntime.server.principalTypes.values.find { it.name == subject } - ?: throw IllegalArgumentException("No subject $subject recognized") - val normalizedValue = handler.normalizePropertyValue(input.property, input.value) + val subject = input.type + val handler = serverRuntime.server.principalTypes.values.find { it.name == subject } + ?: throw IllegalArgumentException("No subject $subject recognized") + // Normalize BEFORE building the rate-limit key: the key must be derived from the canonical + // identifier so that case/whitespace variants (e.g. "Bob@x.com" vs "bob@x.com ") of the same + // account share one bucket. Keying on the raw value would let an attacker dodge the limiter + // (and its exponential backoff) simply by varying case or whitespace. + val normalizedValue = handler.normalizePropertyValue(input.property, input.value) + cache().constrainAttemptRate("password-${input.property}-${normalizedValue}") { val subjectId = handler.fetchUserIdString(input.property, normalizedValue) ?: throw BadRequestException("User ID and code do not match") diff --git a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpoints.kt index f9771ca8c..ebe5ac24f 100644 --- a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpoints.kt +++ b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpoints.kt @@ -64,6 +64,7 @@ public class TimeBasedOTPProofEndpoints( } public val modelInfo: ModelInfo, TotpSecret, Uuid> = database.modelInfo( + tableName = "TotpSecret", auth = proofMethodAuth or AuthRequirement.IsAdmin, permissions = { val admin = condition(AuthRequirement.IsAdmin.accepts(authOrNull)) @@ -153,13 +154,17 @@ public class TimeBasedOTPProofEndpoints( implementation = { input: IdentificationAndPassword -> val now = now() val gracePeriod = now().minus(5.seconds) + val subject = input.type + val handler = serverRuntime.server.principalTypes[subject] + ?: throw IllegalArgumentException("No subject $subject recognized") + // Normalize BEFORE building the rate-limit key: the key must be derived from the canonical + // identifier so that case/whitespace variants of the same account share one bucket. Keying on + // the raw value would let an attacker dodge the limiter (and its exponential backoff) simply + // by varying case or whitespace. + val normalizedValue = handler.normalizePropertyValue(input.property, input.value) cache().constrainAttemptRate( - cacheKey = "totp-count-${input.property}-${input.value}" + cacheKey = "totp-count-${input.property}-${normalizedValue}" ) { - val subject = input.type - val handler = serverRuntime.server.principalTypes[subject] - ?: throw IllegalArgumentException("No subject $subject recognized") - val normalizedValue = handler.normalizePropertyValue(input.property, input.value) val subjectId = handler.fetchUserIdString(input.property, normalizedValue) ?: throw BadRequestException("User ID and code do not match") diff --git a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/WebAuthNProofEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/WebAuthNProofEndpoints.kt index 59c00335a..f8a922846 100644 --- a/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/WebAuthNProofEndpoints.kt +++ b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/WebAuthNProofEndpoints.kt @@ -68,6 +68,7 @@ public class WebAuthNProofEndpoints( } public val modelInfo: ModelInfo, WebAuthNCredential, String> = database.modelInfo( + tableName = "WebAuthNCredential", auth = proofMethodAuth or AuthRequirement.IsAdmin, permissions = { val admin = condition(AuthRequirement.IsAdmin.accepts(authOrNull)) diff --git a/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpointsTest.kt b/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpointsTest.kt index e8375c244..9a151149d 100644 --- a/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpointsTest.kt +++ b/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpointsTest.kt @@ -590,4 +590,73 @@ class BackupCodeEndpointsTest { } } } + + /** + * Security regression: the rate-limit key must be built from the NORMALIZED identifier, so that + * case/whitespace variants of one account share a single bucket. If the key were derived from the + * raw value, an attacker could dodge the limiter (and its exponential backoff) by varying case. + */ + @Test + fun `rate limiter shares one bucket across case variants of the same identifier`() = runBlocking { + TestUser.users.clear() + val userId = Uuid.random() + val user = TestUser(userId, "test@example.com") + TestUser.users[userId] = user + + object : ServerBuilder() { + val database = setting("database", Database.Settings("ram")) + val cache = setting("cache", Cache.Settings("ram")) + + init { + register(TestUser) + } + + val backupCodes = path.path("auth").path("backup") include BackupCodeEndpoints( + database = database, + cache = cache, + proofSigner = RuntimeDeferred.Cached { testBasis.signer("proof") }, + proofExpiration = 1.hours + ) + }.let { server -> + server.test({}) { + server.backupCodes.modelInfo.table().insert( + listOf( + BackupCodeSecret( + code = "validcode", + subjectId = TestUser.idString(userId), + subjectType = TestUser.name + ) + ) + ) + + // Five distinct case variants that all normalize to "test@example.com". The default limit + // is 5 attempts; five failing attempts across these variants must fill ONE shared bucket. + val emailVariants = listOf( + "Test@example.com", + "tEst@example.com", + "teSt@example.com", + "tesT@example.com", + "TEST@example.com", + ) + for (variant in emailVariants) { + assertFailsWith { + server.backupCodes.prove.test( + null, IdentificationAndPassword("TestUser", "email", variant, "wrongcode") + ) + } + } + + // A sixth attempt with yet another distinct variant must be blocked by the shared limiter. + val blocked = assertFailsWith { + server.backupCodes.prove.test( + null, IdentificationAndPassword("TestUser", "email", "TesT@example.com", "wrongcode") + ) + } + assertTrue( + blocked.message.contains("Too many attempts"), + "Expected the shared rate limiter to block, but got: ${blocked.message}" + ) + } + } + } } diff --git a/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpointsTest.kt b/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpointsTest.kt index b00dace4d..2ddfd52c2 100644 --- a/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpointsTest.kt +++ b/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpointsTest.kt @@ -433,4 +433,67 @@ class PasswordProofEndpointsTest { } } } + + /** + * Security regression: the rate-limit key must be built from the NORMALIZED identifier, so that + * case/whitespace variants of one account share a single bucket. If the key were derived from the + * raw value, an attacker could dodge the limiter (and its exponential backoff) by varying case. + */ + @Test + fun `rate limiter shares one bucket across case variants of the same identifier`() = runBlocking { + TestUser.users.clear() + val userId = Uuid.random() + val user = TestUser(userId, "test@example.com") + TestUser.users[userId] = user + + object : ServerBuilder() { + val database = setting("database", Database.Settings("ram")) + val cache = setting("cache", Cache.Settings("ram")) + + init { + register(TestUser) + } + + val passwordProof = path.path("auth").path("password") include PasswordProofEndpoints( + database = database, + cache = cache, + proofSigner = RuntimeDeferred.Cached { testBasis.signer("proof") }, + proofExpiration = 1.hours + ) + }.let { server -> + server.test({}) { + server.passwordProof.establish(TestUser, userId, EstablishPassword("correctPassword")) + + // Five distinct case variants that all normalize to "test@example.com". The default limit + // is 5 attempts; five failing attempts across these variants must fill ONE shared bucket. + val emailVariants = listOf( + "Test@example.com", + "tEst@example.com", + "teSt@example.com", + "tesT@example.com", + "TEST@example.com", + ) + for (variant in emailVariants) { + assertFailsWith { + server.passwordProof.prove.test( + null, IdentificationAndPassword("TestUser", "email", variant, "wrongPassword") + ) + } + } + + // A sixth attempt with yet another distinct variant must be blocked by the shared limiter. + // Under the old raw-value key this variant would be an untouched bucket and fail only with + // the ordinary "does not match" error. + val blocked = assertFailsWith { + server.passwordProof.prove.test( + null, IdentificationAndPassword("TestUser", "email", "TesT@example.com", "wrongPassword") + ) + } + assertTrue( + blocked.message.contains("Too many attempts"), + "Expected the shared rate limiter to block, but got: ${blocked.message}" + ) + } + } + } } diff --git a/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpointsTest.kt b/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpointsTest.kt index c0c3d7f85..afc09ab54 100644 --- a/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpointsTest.kt +++ b/sessions/src/test/kotlin/com/lightningkite/lightningserver/sessions/proofs/TimeBasedOTPProofEndpointsTest.kt @@ -526,4 +526,81 @@ class TimeBasedOTPProofEndpointsTest { } } } + + /** + * Security regression: the rate-limit key must be built from the NORMALIZED identifier, so that + * case/whitespace variants of one account share a single bucket. If the key were derived from the + * raw value, an attacker could dodge the limiter (and its exponential backoff) by varying case. + */ + @Test + fun `rate limiter shares one bucket across case variants of the same identifier`() = runBlocking { + TestUser.users.clear() + val userId = Uuid.random() + val user = TestUser(userId, "test@example.com") + TestUser.users[userId] = user + + object : ServerBuilder() { + val database = setting("database", Database.Settings("ram")) + val cache = setting("cache", Cache.Settings("ram")) + + init { + register(TestUser) + } + + val totpEndpoints = path.path("auth").path("totp") include TimeBasedOTPProofEndpoints( + database = database, + cache = cache, + proofSigner = RuntimeDeferred.Cached { testBasis.signer("proof") }, + proofExpiration = 1.hours, + config = testConfig + ) + }.let { server -> + server.test({}) { + server.totpEndpoints.modelInfo.table().insert( + listOf( + TotpSecret( + subjectId = TestUser.idString(userId), + subjectType = TestUser.name, + secretBase32 = testSecretBase32, + label = "test", + issuer = "TestApp", + period = 30.seconds, + digits = 6, + algorithm = TotpHashAlgorithm.SHA1, + establishedAt = Clock.System.now(), + lastUsedAt = Clock.System.now() + ) + ) + ) + + // Five distinct case variants that all normalize to "test@example.com". The default limit + // is 5 attempts; five failing attempts across these variants must fill ONE shared bucket. + val emailVariants = listOf( + "Test@example.com", + "tEst@example.com", + "teSt@example.com", + "tesT@example.com", + "TEST@example.com", + ) + for (variant in emailVariants) { + assertFailsWith { + server.totpEndpoints.prove.test( + null, IdentificationAndPassword("TestUser", "email", variant, "000000") + ) + } + } + + // A sixth attempt with yet another distinct variant must be blocked by the shared limiter. + val blocked = assertFailsWith { + server.totpEndpoints.prove.test( + null, IdentificationAndPassword("TestUser", "email", "TesT@example.com", "000000") + ) + } + assertTrue( + blocked.message.contains("Too many attempts"), + "Expected the shared rate limiter to block, but got: ${blocked.message}" + ) + } + } + } } diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistration.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistration.kt new file mode 100644 index 000000000..7822585e7 --- /dev/null +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistration.kt @@ -0,0 +1,97 @@ +package com.lightningkite.lightningserver.typed + +import com.lightningkite.lightningserver.definition.builder.* +import com.lightningkite.lightningserver.definition.* +import com.lightningkite.lightningserver.runtime.ServerRuntime +import com.lightningkite.services.database.Database +import com.lightningkite.services.database.DatabaseTableDefinition +import com.lightningkite.services.database.Table +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer + +/** + * A table that has been registered on a [ServerBuilder] via [registerTable]: its [database], its + * [tableDefinition], and the [preDeployTask] that reconciles it (creates the collection/indexes) + * once per deploy. + * + * The registration is itself a [Runtime]<[Table]> — invoke it inside a [ServerRuntime] to get the + * live table (`myTable()`). All registrations are enumerable at runtime through + * [ServerDefinition.allRegisteredTables], keyed by table name. + */ +public data class DatabaseTableRegistration( + val database: Runtime, + val tableDefinition: DatabaseTableDefinition, + val preDeployTask: PreDeployTask, +) : Runtime> { + context(server: ServerRuntime) + override fun invoke(): Table = database().table(tableDefinition) +} + +private fun DatabaseTableRegistration<*>.typeName(): String = tableDefinition.serializer.descriptor.serialName + +private object KnownTablesExtensionKey : MapRegistryExtension> { + // The same table is legitimately registered from more than one place (a model served by several + // endpoint groups, or a module mounted at two paths). Merge those idempotently by name (keep the + // first) instead of failing; only reject a name reused for a genuinely different table (type). + override fun MapRegistry>.include(other: Map>) { + for ((name, reg) in other) { + val existing = this[name] + if (existing == null) register(name, reg) + else require(existing.typeName() == reg.typeName()) { + "Table \"$name\" is registered for two different types (${existing.typeName()} vs ${reg.typeName()})." + } + } + } +} + +private val ServerBuilder.allRegisteredTables: MapRegistry> by KnownTablesExtensionKey + +/** + * Every table registered on this server via [registerTable], keyed by table name. + * + * Populated at definition-build time. Enables server-wide functionality that needs to enumerate + * tables (e.g. preparing or introspecting all of them) without hard-coding the list. + */ +public val ServerDefinition.allRegisteredTables: Map> by KnownTablesExtensionKey + +@Deprecated("It is strongly recommended you define the table name explicitly.") +context(builder: ServerBuilder) +public inline fun Runtime.registerTable(): DatabaseTableRegistration = + registerTable(T::class.simpleName!!, serializer()) + +context(builder: ServerBuilder) +public inline fun Runtime.registerTable(name: String): DatabaseTableRegistration = + registerTable(name, serializer()) + +/** + * Defines a table, registers it on the [builder] (see [ServerDefinition.allRegisteredTables]), and + * creates its once-per-deploy prepare task. Returns a [DatabaseTableRegistration], which is a runtime + * accessor for the table — invoke it inside a handler to use it. + * + * Idempotent by [name]: registering the same table again (e.g. a model served by multiple endpoint + * groups) returns the existing registration rather than creating a duplicate prepare task. Reusing a + * name for a genuinely *different* table (a different type) throws. Table names are unique per server. + */ +context(builder: ServerBuilder) +public fun Runtime.registerTable( + name: String, + serializer: KSerializer, +): DatabaseTableRegistration { + builder.allRegisteredTables[name]?.let { existing -> + require(existing.typeName() == serializer.descriptor.serialName) { + "Table \"$name\" is already registered for a different type (${existing.typeName()} vs ${serializer.descriptor.serialName})." + } + @Suppress("UNCHECKED_CAST") + return existing as DatabaseTableRegistration + } + val def = DatabaseTableDefinition(serializer, name) + val task = with(builder) { + path.path("prepare-$name") bind PreDeployTask { + this@registerTable().prepare(def) + Unit + } + } + val reg = DatabaseTableRegistration(this@registerTable, def, task) + builder.allRegisteredTables.register(name, reg) + return reg +} diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/FunnelEndpoints.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/FunnelEndpoints.kt index 8ae4c6bc7..8dba94db0 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/FunnelEndpoints.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/FunnelEndpoints.kt @@ -25,6 +25,7 @@ public class FunnelEndpoints( ) : ServerBuilder() { public val summaryInfo: ModelInfo, FunnelSummary, Uuid> = database.modelInfo( + tableName = "FunnelSummary", auth = read, permissions = { ModelPermissions.allowAll() } ) @@ -33,6 +34,7 @@ public class FunnelEndpoints( path.path("summary").path("rest") module ModelRestEndpoints(summaryInfo).withSdkInfo(valueName = "summaries") public val info: ModelInfo, FunnelInstance, Uuid> = database.modelInfo( + tableName = "FunnelInstance", auth = read, permissions = { ModelPermissions.allowAll() } ) diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt index 4460ba901..537f485d7 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt @@ -1,8 +1,8 @@ package com.lightningkite.lightningserver.typed import com.lightningkite.lightningserver.auth.* +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.Runtime -import com.lightningkite.lightningserver.definition.StartupTask import com.lightningkite.lightningserver.definition.builder.ServerBuilder import com.lightningkite.lightningserver.runtime.ServerRuntime import com.lightningkite.services.database.* @@ -14,6 +14,8 @@ public interface ModelInfo?, T : HasId, ID : Comparable + public val registration: DatabaseTableRegistration + public object Scopes { public val create: Subscope = Subscope("create") public val read: Subscope = Subscope("read") @@ -45,8 +47,7 @@ public interface ModelInfo?, T : HasId, ID : Comparable?, reified T : HasId, reified ID : Comparable> Runtime.modelInfo( auth: AuthRequirement, - tableName: String = serializerOrContextual().descriptor.serialName.substringBefore('/').substringBefore('<') - .substringAfterLast('.'), + tableName: String, subscope: Subscope? = Subscope(tableName.lowercase()), crossinline signals: context(ServerRuntime) (Table) -> Table = { it }, crossinline log: context(ServerRuntime) AuthAccess?.(Table) -> Table = { it }, @@ -60,15 +61,12 @@ public inline fun ?, reified T : HasId, reified ID : override val auth: AuthRequirement = subscope?.let { auth.subscope(it) } ?: auth - val tableDefinition = DatabaseTableDefinition(serializer, tableName) - val startupTask = with(builder) { - path.path(tableName) bind StartupTask { - this@modelInfo().prepare(tableDefinition) - } - } + // registerTable defines the table, registers it, and creates its (once-per-deploy) prepare task. + override val registration: DatabaseTableRegistration = + with(builder) { this@modelInfo.registerTable(tableName, serializer) } context(server: ServerRuntime) - override fun baseTable(): Table = this@modelInfo().table(tableDefinition) + override fun baseTable(): Table = registration() override val tableName: String get() = tableName @@ -100,7 +98,7 @@ public fun ?, T : HasId, ID : Comparable> Runtime, serializer: KSerializer, idSerializer: KSerializer, - tableName: String = serializer.descriptor.serialName.substringBefore('<').substringAfterLast('.'), + tableName: String, subscope: Subscope? = Subscope(tableName.lowercase()), signals: context(ServerRuntime) (Table) -> Table = { it }, log: context(ServerRuntime) AuthAccess?.(Table) -> Table = { it }, @@ -114,8 +112,12 @@ public fun ?, T : HasId, ID : Comparable> Runtime = subscope?.let { auth.subscope(it) } ?: auth + // registerTable defines the table, registers it, and creates its (once-per-deploy) prepare task. + override val registration: DatabaseTableRegistration = + with(builder) { this@explicitModelInfo.registerTable(tableName, serializer) } + context(server: ServerRuntime) - override fun baseTable(): Table = this@explicitModelInfo().table(serializer, tableName) + override fun baseTable(): Table = registration() override val tableName: String get() = tableName @@ -149,4 +151,4 @@ public suspend fun , T : HasId, ID : Comparable> ModelIn @JvmName("authTableNullable") context(_: ServerRuntime) public suspend fun ?, T : HasId, ID : Comparable> ModelInfo.table(auth: Authentication?): Table = - table(AuthAccess(auth)) \ No newline at end of file + table(AuthAccess(auth)) diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpoints.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpoints.kt index 361351393..e68f29387 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpoints.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpoints.kt @@ -28,6 +28,19 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable public val detailPath: PathSpec1 = path.arg(Segment.Wildcard("id", info.idSerializer)) private val bulkPath = path.path("bulk") + // Errors actually thrown by the implementations below; declared so docs/SDKs advertise them and the + // W6 "undeclared error" advisory stays quiet. + private val notFoundError = LSError( + http = HttpStatus.NotFound.code, + detail = "", + message = "There was no known object by that ID.", + ) + private val uniqueViolationError = LSError( + http = HttpStatus.BadRequest.code, + detail = "unique", + message = "A unique constraint was violated.", + ) + public val permissions: ApiHttpHandler> = path.path("_permissions_").get bind explicitApiHttpHandler( summary = "Permissions", @@ -101,14 +114,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = Unit.serializer(), outputType = info.serializer, auth = info.auth.subscope(ModelInfo.Scopes.read), - errorCases = listOf( - LSError( - http = HttpStatus.NotFound.code, - detail = "", - message = "There was no known object by that ID.", - data = "" - ) - ), + errorCases = listOf(notFoundError), examples = emptyList(), implementation = { _: Unit -> info.table(this).get(route.arg1) ?: throw NotFoundException() @@ -123,7 +129,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = ListSerializer(info.serializer), outputType = ListSerializer(info.serializer), auth = info.auth.subscope(ModelInfo.Scopes.create), - errorCases = emptyList(), + errorCases = listOf(uniqueViolationError), examples = emptyList(), implementation = { values: List -> try { @@ -147,7 +153,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = info.serializer, outputType = info.serializer, auth = info.auth.subscope(ModelInfo.Scopes.create), - errorCases = emptyList(), + errorCases = listOf(uniqueViolationError), examples = emptyList(), implementation = { value: T -> try { @@ -171,7 +177,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = info.serializer, outputType = info.serializer, auth = info.auth.subscope(listOf(ModelInfo.Scopes.create, ModelInfo.Scopes.update)), - errorCases = emptyList(), + errorCases = listOf(notFoundError, uniqueViolationError), examples = emptyList(), implementation = { value: T -> try { @@ -197,7 +203,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = ListSerializer(info.serializer), outputType = ListSerializer(info.serializer), auth = info.auth.subscope(ModelInfo.Scopes.update), - errorCases = emptyList(), + errorCases = listOf(uniqueViolationError), examples = emptyList(), implementation = { values: List -> try { @@ -221,7 +227,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = info.serializer, outputType = info.serializer, auth = info.auth.subscope(ModelInfo.Scopes.update), - errorCases = emptyList(), + errorCases = listOf(notFoundError, uniqueViolationError), examples = emptyList(), implementation = { value: T -> try { @@ -247,7 +253,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = MassModification.serializer(info.serializer), outputType = Int.serializer(), auth = info.auth.subscope(ModelInfo.Scopes.update), - errorCases = emptyList(), + errorCases = listOf(uniqueViolationError), examples = emptyList(), implementation = { input: MassModification -> try { @@ -271,14 +277,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = Modification.serializer(info.serializer), outputType = EntryChange.serializer(info.serializer), auth = info.auth.subscope(ModelInfo.Scopes.update), - errorCases = listOf( - LSError( - http = HttpStatus.NotFound.code, - detail = "", - message = "There was no known object by that ID.", - data = "" - ) - ), + errorCases = listOf(notFoundError, uniqueViolationError), examples = emptyList(), implementation = { input: Modification -> try { @@ -303,14 +302,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = Modification.serializer(info.serializer), outputType = info.serializer, auth = info.auth.subscope(ModelInfo.Scopes.update), - errorCases = listOf( - LSError( - http = HttpStatus.NotFound.code, - detail = "", - message = "There was no known object by that ID.", - data = "" - ) - ), + errorCases = listOf(notFoundError, uniqueViolationError), examples = emptyList(), implementation = { input: Modification -> try { @@ -336,14 +328,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = PartialSerializer(info.serializer), outputType = info.serializer, auth = info.auth.subscope(ModelInfo.Scopes.update), - errorCases = listOf( - LSError( - http = HttpStatus.NotFound.code, - detail = "", - message = "There was no known object by that ID.", - data = "" - ) - ), + errorCases = listOf(notFoundError, uniqueViolationError), examples = emptyList(), implementation = { input: Partial -> try { @@ -384,14 +369,7 @@ public class ModelRestEndpoints?, T : HasId, ID : Comparable inputType = Unit.serializer(), outputType = Unit.serializer(), auth = info.auth.subscope(ModelInfo.Scopes.delete), - errorCases = listOf( - LSError( - http = HttpStatus.NotFound.code, - detail = "", - message = "There was no known object by that ID.", - data = "" - ) - ), + errorCases = listOf(notFoundError), examples = emptyList(), implementation = { _: Unit -> if (!info.table(this).deleteOneById(route.arg1)) { diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/doOnce.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/doOnce.kt index 4b559419e..332479924 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/doOnce.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/doOnce.kt @@ -1,5 +1,6 @@ package com.lightningkite.lightningserver.typed +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.Runtime import com.lightningkite.lightningserver.definition.StartupTask import com.lightningkite.lightningserver.runtime.* @@ -20,6 +21,8 @@ public data class ActionHasOccurred( val errorMessage: String? = null, ) : HasId +private val actionHasOccurredTable = DatabaseTableDefinition() + context(runtime: ServerRuntime) public suspend fun doOnce( key: String, @@ -27,7 +30,7 @@ public suspend fun doOnce( timeout: Duration = 60.seconds, action: suspend context(ServerRuntime) () -> Unit, ) { - val table = database().table() + val table = database().prepare(actionHasOccurredTable) val existing = table.get(key) if (existing == null) { @@ -79,7 +82,39 @@ public fun startupOnce( val oldKey = location.segments.lastOrNull()?.toString() ?: "" val newKey = location.toString() - val table = database().table() + val table = database().prepare(actionHasOccurredTable) + + if (table.get(newKey) == null) table.get(oldKey)?.let { old -> + table.insertOne( + old.copy(_id = newKey) + ) + } + } + + doOnce( + key = location.toString(), + database = database, + timeout = timeout, + action = action + ) + } + +/** + * Creates a [StartupTask] that calls [doOnce] with its bound path as its key. + * */ +public fun predeployOnce( + database: Runtime, + migrateKey: Boolean = false, + dependencies: () -> List = { emptyList() }, + timeout: Duration = 60.seconds, + action: suspend context(ServerRuntime) () -> Unit, +): PreDeployTask = + PreDeployTask(dependencies, timeout) { + if (migrateKey) { + val oldKey = location.segments.lastOrNull()?.toString() ?: "" + val newKey = location.toString() + + val table = database().prepare(actionHasOccurredTable) if (table.get(newKey) == null) table.get(oldKey)?.let { old -> table.insertOne( diff --git a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApi.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApi.kt index b9837d9ea..e3d70a772 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApi.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApi.kt @@ -1,6 +1,7 @@ package com.lightningkite.lightningserver.typed.jsonschema import com.lightningkite.lightningserver.HttpMethod +import com.lightningkite.lightningserver.LSError import com.lightningkite.lightningserver.definition.generalSettings import com.lightningkite.lightningserver.pathing.PathSpec import com.lightningkite.lightningserver.pathing.plus @@ -218,9 +219,31 @@ private fun ?, INPUT, OUTPUT> ApiHttpHandler + OpenApiResponse( + description = cases.joinToString("\n") { case -> + val prefix = if (case.detail.isNotBlank()) "[${case.detail}] " else "" + prefix + case.message.ifBlank { "Error" } + }, + content = mapOf( + MediaType.Application.Json.toString() to OpenApiMediaType( + schema = builder[LSError.serializer()], + example = runtime.externalSerialization.json.encodeToJsonElement( + LSError.serializer(), + cases.first() + ) + ) + ) + ) + } + + mapOf(successCode.code.toString() to response) + errorResponses } - // TODO: Error codes ) context(runtime: ServerRuntime) diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistrationTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistrationTest.kt new file mode 100644 index 000000000..6640aaa61 --- /dev/null +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistrationTest.kt @@ -0,0 +1,61 @@ +package com.lightningkite.lightningserver.typed + +import com.lightningkite.lightningserver.definition.builder.ServerBuilder +import com.lightningkite.services.database.* +import kotlinx.serialization.Serializable +import kotlin.test.* +import kotlin.uuid.Uuid + +class DatabaseTableRegistrationTest { + + @Serializable + data class RegNote(override val _id: Uuid = Uuid.random(), val title: String = "") : HasId + + @Serializable + data class RegOther(override val _id: Uuid = Uuid.random()) : HasId + + @Test + fun `registerTable is idempotent by name and enumerable at runtime`() { + val server = object : ServerBuilder() { + val database = setting("database", Database.Settings()) + val a = database.registerTable("RegNote") + val b = database.registerTable("RegNote") // same name + type + } + // Second registration returns the first — no duplicate prepare task, shared safely. + assertSame(server.a, server.b) + + val definition = server.build() + assertEquals(setOf("RegNote"), definition.allRegisteredTables.keys) + assertEquals("RegNote", definition.allRegisteredTables.getValue("RegNote").tableDefinition.name) + } + + @Test + fun `registerTable rejects a name reused for a different type`() { + assertFailsWith { + object : ServerBuilder() { + val database = setting("database", Database.Settings()) + val a = database.registerTable("Shared") + val b = database.registerTable("Shared") // same name, different type + } + } + } + + @Test + fun `the same table registered across modules merges to one entry`() { + val moduleA = object : ServerBuilder() { + val database = setting("database", Database.Settings()) + val note = database.registerTable("RegNote") + } + val moduleB = object : ServerBuilder() { + val database = setting("database", Database.Settings()) + val note = database.registerTable("RegNote") // same table, another module + val other = database.registerTable("RegOther") + } + val root = object : ServerBuilder() { + val a = path.path("a") include moduleA + val b = path.path("b") include moduleB + } + // RegNote appears in both modules but merges to one entry (idempotent by name). + assertEquals(setOf("RegNote", "RegOther"), root.build().allRegisteredTables.keys) + } +} diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DoOnceTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DoOnceTest.kt index f238428fb..e00c7140e 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DoOnceTest.kt +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DoOnceTest.kt @@ -18,6 +18,8 @@ import kotlin.time.Duration.Companion.seconds * - Timeout-based lock acquisition */ class DoOnceTest { + private val actionTable = DatabaseTableDefinition() + object TestServer : ServerBuilder() { val database = setting("database", Database.Settings()) @@ -115,7 +117,7 @@ class DoOnceTest { // Empty action } - val table = database().table() + val table = database().table(actionTable) val record = table.get("record-test") assertNotNull(record, "Record should be created in database") @@ -134,7 +136,7 @@ class DoOnceTest { // Successful action } - val table = database().table() + val table = database().table(actionTable) val record = table.get("completion-test") assertNotNull(record?.completed, "completed should be set on success") @@ -158,7 +160,7 @@ class DoOnceTest { // Expected } - val table = database().table() + val table = database().table(actionTable) val record = table.get("error-test") assertNotNull(record, "Record should exist after error") @@ -231,7 +233,7 @@ class DoOnceTest { // Action completes } - val table = database().table() + val table = database().table(actionTable) val record = table.get("default-timeout-test") assertNotNull(record?.completed) } @@ -248,7 +250,7 @@ class DoOnceTest { // Action completes } - val table = database().table() + val table = database().table(actionTable) val record = table.get("custom-timeout-test") assertNotNull(record?.completed) } diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt index 4cea1d490..549eaf043 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt @@ -23,6 +23,7 @@ class ModelRestEndpointsTest { object CrudTestServer : ServerBuilder() { val database = setting("database", Database.Settings()) val info = database.modelInfo?, CrudItem, Uuid>( + tableName = "CrudItem", auth = noAuth, permissions = { ModelPermissions.allowAll() } ) @@ -552,6 +553,37 @@ class ModelRestEndpointsTest { } } + // The endpoints below catch UniqueViolationException and rethrow BadRequestException(detail="unique"), + // or throw NotFoundException; declaring those cases keeps the W6 "undeclared error" advisory quiet and + // documents the errors. See ModelRestEndpoints implementations. + private fun declaresUnique(handler: ApiHttpHandler<*, *, *, *>) = + handler.errorCases.any { it.http == 400 && it.detail == "unique" } + + private fun declaresNotFound(handler: ApiHttpHandler<*, *, *, *>) = + handler.errorCases.any { it.http == 404 } + + @Test + fun endpoints_declare_the_errors_they_throw() { + val rest = CrudTestServer.rest + + // Every endpoint that catches UniqueViolationException declares the 400 "unique" case. + listOf(rest.insert, rest.insertBulk, rest.upsert, rest.bulkReplace, rest.replace, + rest.bulkModify, rest.modifyWithDiff, rest.modify, rest.modifySimple).forEach { + assertTrue(declaresUnique(it), "expected 400:unique in errorCases") + } + + // Every endpoint that can throw NotFoundException declares the 404 case. + listOf(rest.detail, rest.upsert, rest.replace, rest.modifyWithDiff, rest.modify, + rest.modifySimple, rest.deleteItem).forEach { + assertTrue(declaresNotFound(it), "expected 404 in errorCases") + } + + // Read-only endpoints that never throw keep an empty error list. + listOf(rest.list, rest.query, rest.count, rest.permissions).forEach { + assertTrue(it.errorCases.isEmpty(), "read-only endpoint should declare no errors") + } + } + @Test fun bulkDelete_with_Never_condition_deletes_nothing() = runBlocking { CrudTestServer.test(settings = { diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestUpdatesWebsocketTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestUpdatesWebsocketTest.kt index 467bdd413..951bf3437 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestUpdatesWebsocketTest.kt +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestUpdatesWebsocketTest.kt @@ -22,6 +22,7 @@ class ModelRestUpdatesWebsocketTest { object TestServer : ServerBuilder() { val database = setting("database", Database.Settings()) val info = database.modelInfo?, Sample, String>( + tableName = "Sample", auth = noAuth, permissions = { ModelPermissions.allowAll() } ) diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApiTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApiTest.kt new file mode 100644 index 000000000..fbb287e41 --- /dev/null +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApiTest.kt @@ -0,0 +1,83 @@ +package com.lightningkite.lightningserver.typed.jsonschema + +import com.lightningkite.lightningserver.LSError +import com.lightningkite.lightningserver.auth.noAuth +import com.lightningkite.lightningserver.definition.builder.ServerBuilder +import com.lightningkite.lightningserver.http.* +import com.lightningkite.lightningserver.runtime.test.test +import com.lightningkite.lightningserver.serialization.registerBasicMediaTypeCoders +import com.lightningkite.lightningserver.typed.ApiHttpHandler +import com.lightningkite.services.data.MediaType +import kotlinx.coroutines.runBlocking +import kotlin.test.* + +/** + * Verifies that [openApiDescription] documents declared error cases as responses and emits path + * arguments as `in: path` parameters. + */ +class OpenApiTest { + + object TestServer : ServerBuilder() { + init { registerBasicMediaTypeCoders() } + + // Endpoint with a path argument and two declared error cases, one of them sharing a status code. + val getItem = path.path("items").arg("id").get bind ApiHttpHandler( + summary = "Get Item", + auth = noAuth, + errorCases = listOf( + LSError(http = 404, detail = "not-found", message = "No such item"), + LSError(http = 400, detail = "bad-id", message = "Malformed id"), + LSError(http = 400, detail = "blocked", message = "Access blocked"), + ), + implementation = { _: Unit -> "value" } + ) + + // Zero-argument endpoint to prove nothing breaks without path arguments. + val root = path.get bind ApiHttpHandler( + summary = "Root", + auth = noAuth, + implementation = { _: Unit -> "ok" } + ) + } + + @Test + fun errorCasesAppearAsResponses() = runBlocking { + TestServer.test({}) { + val op = openApiDescription.paths.entries.first { it.key.contains("items") }.value.get + assertNotNull(op) + + // Success response is still present. + assertTrue(op.responses.containsKey("200"), "success response should remain") + + // Declared error statuses are documented with the LSError schema and an example. + val notFound = op.responses["404"] + assertNotNull(notFound, "404 error case should be documented") + val notFoundMedia = notFound.content[MediaType.Application.Json.toString()] + assertNotNull(notFoundMedia) + assertNotNull(notFoundMedia.schema.ref, "error response should reference the LSError schema") + + // The two 400 cases are grouped into a single response with a combined description. + val badRequest = op.responses["400"] + assertNotNull(badRequest, "400 error cases should be documented") + assertTrue(badRequest.description.contains("bad-id")) + assertTrue(badRequest.description.contains("blocked")) + } + } + + @Test + fun pathArgumentsAppearAsParameters() = runBlocking { + TestServer.test({}) { + val paths = openApiDescription.paths + + val itemsPath = paths.entries.first { it.key.contains("items") }.value + val idParam = itemsPath.parameters.singleOrNull { it.name == "id" } + assertNotNull(idParam, "path argument should be emitted as a parameter") + assertEquals(OpenApiParameterType.path, idParam.inside) + assertTrue(idParam.required, "path parameters must be required") + + // Zero-argument endpoints emit no path parameters. + val rootPath = paths.entries.first { it.key == "/" || it.key.isEmpty() }.value + assertTrue(rootPath.parameters.none { it.inside == OpenApiParameterType.path }) + } + } +} diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/Api.ts b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/Api.ts index ebaf46e09..57af22681 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/Api.ts +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/Api.ts @@ -1,5 +1,5 @@ import type { Query, MassModification, EntryChange, ListChange, Modification, Condition, GroupCountQuery, AggregateQuery, GroupAggregateQuery, Aggregate, SortPart, DataClassPath, DataClassPathPartial, QueryPartial, DeepPartial, Fetcher } from '@lightningkite/lightning-server-simplified' -import type { CollectionUpdates, Mask, Mode, ModelPermissions, Pair, Part, TestInput, TestModel, UpdateRestrictions, Uuid } from './models.ts' +import type { CollectionUpdates, Mask, ModelPermissions, Pair, TestInput, TestModel, UpdateRestrictions, Uuid } from './models.ts' export interface Api { index(): Promise diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/LiveApi.ts b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/LiveApi.ts index b96106ea6..512dbc667 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/LiveApi.ts +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/LiveApi.ts @@ -1,5 +1,5 @@ import type { Query, MassModification, EntryChange, ListChange, Modification, Condition, GroupCountQuery, AggregateQuery, GroupAggregateQuery, Aggregate, SortPart, DataClassPath, DataClassPathPartial, QueryPartial, DeepPartial, Fetcher } from '@lightningkite/lightning-server-simplified' -import type { CollectionUpdates, Mask, Mode, ModelPermissions, Pair, Part, TestInput, TestModel, UpdateRestrictions, Uuid } from './models.ts' +import type { CollectionUpdates, Mask, ModelPermissions, Pair, TestInput, TestModel, UpdateRestrictions, Uuid } from './models.ts' import type { Api } from './Api.ts' export class LiveApi implements Api { diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/models.ts b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/models.ts index 5dc3749e2..a55e2b0fd 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/models.ts +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/models.ts @@ -11,17 +11,12 @@ export interface Mask { pairs: Array, Modification>> } -export enum Mode { - Blacklist = "Blacklist", - Whitelist = "Whitelist", -} - export interface ModelPermissions { create: Condition read: Condition readMask: Mask update: Condition - updateRestrictions: UpdateRestrictions + updateRestrictions: UpdateRestrictions delete: Condition maxQueryTimeMs: number } @@ -31,12 +26,6 @@ export interface Pair { second: T1 } -export interface Part { - property: DataClassPathPartial - requires: Condition - limitedTo: Condition -} - export interface TestInput { id: number name: string @@ -47,9 +36,7 @@ export interface TestModel { name: string } -export interface UpdateRestrictions { - mode: Mode - fields: Array> +export interface UpdateRestrictions { } export type Uuid = string // kotlin.uuid.Uuid diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/sdk.ts b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/sdk.ts index 93c3e2ab4..ac109e2b0 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/sdk.ts +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/sdk.ts @@ -11,17 +11,12 @@ export interface Mask { pairs: Array, Modification>> } -export enum Mode { - Blacklist = "Blacklist", - Whitelist = "Whitelist", -} - export interface ModelPermissions { create: Condition read: Condition readMask: Mask update: Condition - updateRestrictions: UpdateRestrictions + updateRestrictions: UpdateRestrictions delete: Condition maxQueryTimeMs: number } @@ -31,12 +26,6 @@ export interface Pair { second: T1 } -export interface Part { - property: DataClassPathPartial - requires: Condition - limitedTo: Condition -} - export interface TestInput { id: number name: string @@ -47,9 +36,7 @@ export interface TestModel { name: string } -export interface UpdateRestrictions { - mode: Mode - fields: Array> +export interface UpdateRestrictions { } export type Uuid = string // kotlin.uuid.Uuid diff --git a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/server.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/server.kt index 55acd5619..8eaa6895a 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/server.kt +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/server.kt @@ -96,6 +96,7 @@ data class TestModel( object Module : ServerBuilder() { val info = Server.database.modelInfo( auth = noAuth, + tableName = "TestModel", permissions = { ModelPermissions.allowAll() } ) @@ -119,6 +120,7 @@ object SecondModule : ServerBuilder() { val info = Server.database.modelInfo( auth = anyAuth, + tableName = "TestModel", permissions = { ModelPermissions.allowAll() } ) @@ -132,6 +134,7 @@ object SecondModule : ServerBuilder() { object ThirdModule : ServerBuilder() { val info = Server.database.modelInfo( auth = noAuth, + tableName = "TestModel", permissions = { ModelPermissions.allowAll() } )