Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .claude/skills/lightning-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Post>()
val postTable = database.registerTable<Post>("Post") // in your ServerBuilder: define + register + prepare
val posts = postTable() // inside a handler: the live Table<Post>

// Insert
posts.insertOne(Post(title = "Hello", content = "World"))
Expand Down Expand Up @@ -325,12 +328,14 @@ val sendEmail = path.path("send-email").post bind HttpHandler { request ->
HttpResponse.plainText("Email queued")
}

val oldDataTable = database.registerTable<OldData>("OldData") // define + register + prepare, once

// Scheduled task
val cleanup = path.path("scheduled-cleanup") bind ScheduledTask(
frequency = 1.hours
) {
println("Running cleanup...")
database().table<OldData>().deleteMany(condition {
oldDataTable().deleteMany(condition {
it.createdAt lt Clock.System.now() - 30.days
})
}
Expand All @@ -351,11 +356,12 @@ val value = cache().get<String>("key")
cache().remove("key")

// Cache-aside pattern
// dataTable is registered in your ServerBuilder: val dataTable = database.registerTable<Data>("Data")
suspend fun getExpensiveData(id: String): Data {
val cached = cache().get<Data>("data:$id")
if (cached != null) return cached

val fresh = database().table<Data>().get(id)
val fresh = dataTable().get(id)
cache().set("data:$id", fresh, expire = 10.minutes)
return fresh
}
Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,16 @@ data class Post(
) : HasId<Uuid>
```

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<Post>()
// in your ServerBuilder:
val postTable = database.registerTable<Post>("Post") // define + register + prepare, once

// inside a handler:
val posts = postTable()

// Insert
posts.insertOne(Post(title = "Test", author = "user@example.com", body = "Content"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ import kotlin.time.Duration.Companion.minutes
* companion object : PrincipalType<User, Uuid> {
* override val idSerializer = Uuid.serializer()
* override val subjectSerializer = serializer()
* val table = DatabaseTableDefinition<User>() // define once, reuse everywhere
*
* context(server: ServerRuntime)
* override suspend fun fetch(id: Uuid): User {
* return database().table<User>().get(id)
* return database().table(table).get(id)
* ?: throw NotFoundException("User not found")
* }
* }
Expand Down Expand Up @@ -199,7 +200,7 @@ public interface PrincipalType<SUBJECT : HasId<ID>, ID : Comparable<ID>> {
* Consider adding a registration mechanism for indexed properties:
* ```kotlin
* val indices = mapOf(
* "email" to { email: String -> database().table<User>().find { it.email eq email }.first() }
* "email" to { email: String -> database().table(userTable).find { it.email eq email }.first() }
* )
* ```
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>("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<User>().get(id)
return Server.userTable().get(id)
?: throw NotFoundException("User not found")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any, Any>
val fresh = key.default()
key.run { fresh.include(value) }
map[key] = fresh
} else {
map[key] = value
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ public interface HttpHandler<PATH : PathSpec> {
*
* Example:
* ```kotlin
* // Define the table once and share it; it is the key backends use to locate the table.
* val userTable = DatabaseTableDefinition<User>()
*
* val getUser = path.path("users").arg<String>("id").get bind HttpHandler { request ->
* val userId = request.path.arg1
* val user = database().table<User>().get(userId)
* val user = database().table(userTable).get(userId)
* HttpResponse.json(user)
* }
* ```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>("User")

val endpoint = path.path("users").arg<String>("id").get bind HttpHandler { request ->
val userId = request.path.arg1
val user = database().table<User>().get(userId)
val user = userTable().get(userId)
HttpResponse.json(user)
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlogPost>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, Uuid> {
Expand All @@ -142,6 +147,7 @@ object Server : ServerBuilder() {

val userInfo: ModelInfo<User?, User, Uuid> = database.modelInfo(
auth = UserAuth.require() or AuthRequirement.None,
tableName = "User",
permissions = {
val user = authOrNull?.fetch()
val everyone: Condition<User> = Condition.Always
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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.*
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
Expand Down Expand Up @@ -54,6 +56,11 @@ class DatabaseExamplesEndpoints(
private val database: Runtime<Database>,
) : 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>("BlogPost")
private val commentTable = database.registerTable<Comment>("Comment")

/**
* POST /blog/posts
*
Expand Down Expand Up @@ -85,7 +92,7 @@ class DatabaseExamplesEndpoints(
status = PostStatus.DRAFT
)

database().table<BlogPost>().insertOne(post)
postTable().insertOne(post)
}
)

Expand All @@ -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<BlogPost>()
val posts = postTable()

// Simple condition for published posts
val condition: Condition<BlogPost> = condition { it.status eq PostStatus.PUBLISHED }
Expand Down Expand Up @@ -141,7 +148,7 @@ class DatabaseExamplesEndpoints(
successCode = HttpStatus.OK,
implementation = { _: Unit ->
val id = route.arg1
val posts = database().table<BlogPost>()
val posts = postTable()

val post = posts.get(id) ?: throw NotFoundException("Blog post not found")

Expand Down Expand Up @@ -171,7 +178,7 @@ class DatabaseExamplesEndpoints(
successCode = HttpStatus.OK,
implementation = { input: UpdatePostRequest ->
val id = route.arg1
val posts = database().table<BlogPost>()
val posts = postTable()

// Check if post exists
posts.get(id) ?: throw NotFoundException("Blog post not found")
Expand Down Expand Up @@ -220,8 +227,8 @@ class DatabaseExamplesEndpoints(
successCode = HttpStatus.NoContent,
implementation = { _: Unit ->
val id = route.arg1
val posts = database().table<BlogPost>()
val comments = database().table<Comment>()
val posts = postTable()
val comments = commentTable()

// Check if post exists
posts.get(id) ?: throw NotFoundException("Blog post not found")
Expand Down Expand Up @@ -251,7 +258,7 @@ class DatabaseExamplesEndpoints(
successCode = HttpStatus.Created,
implementation = { input: CreateCommentRequest ->
val postId = route.arg1
val posts = database().table<BlogPost>()
val posts = postTable()

// Verify post exists
posts.get(postId) ?: throw NotFoundException("Blog post not found")
Expand All @@ -267,7 +274,7 @@ class DatabaseExamplesEndpoints(
parentCommentId = input.parentCommentId
)

database().table<Comment>().insertOne(comment)
commentTable().insertOne(comment)
}
)

Expand All @@ -285,7 +292,7 @@ class DatabaseExamplesEndpoints(
implementation = { _: Unit ->
val postId = route.arg1

database().table<Comment>()
commentTable()
.find(
condition = condition {
(it.postId eq postId) and (it.isApproved eq true)
Expand All @@ -308,7 +315,7 @@ class DatabaseExamplesEndpoints(
auth = noAuth,
successCode = HttpStatus.OK,
implementation = { input: SearchPostsRequest ->
val posts = database().table<BlogPost>()
val posts = postTable()

// Start with published posts only
var condition: Condition<BlogPost> = condition { it.status eq PostStatus.PUBLISHED }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
)
}

Expand Down
Loading