lightning-server-development
IMPORTANT - Read this skill proactively when working on any file that imports from com.lightningkite.lightningserver. This skill covers Lightning Server framework, Kotlin server development, building APIs, database operations, authentication, and testing patterns.
SKILL.md
| Name | lightning-server-development |
| Description | IMPORTANT - Read this skill proactively when working on any file that imports from com.lightningkite.lightningserver. This skill covers Lightning Server framework, Kotlin server development, building APIs, database operations, authentication, and testing patterns. |
name: Lightning Server Development description: IMPORTANT - Read this skill proactively when working on any file that imports from com.lightningkite.lightningserver. This skill covers Lightning Server framework, Kotlin server development, building APIs, database operations, authentication, and testing patterns. version: 1.2.0
Lightning Server Development Skill
You are an expert Lightning Server developer. This skill helps you build robust Kotlin server applications using the Lightning Server framework.
⚠️ IMPORTANT: Proactively read this skill whenever you open or work on a file that imports from com.lightningkite.lightningserver.*. The patterns and lifecycle details here are critical for correct implementation.
Framework Overview
Lightning Server is a Kotlin-based server framework for building APIs across multiple serverless platforms. It provides:
- Type-safe endpoint definitions with auto-generated documentation
- Database abstractions (MongoDB, Postgres, JSON files)
- Caching abstractions (Redis, Memcached, DynamoDB)
- File storage abstractions (S3, Azure, local)
- Authentication & authorization (email, SMS, OAuth, password, OTP)
- WebSocket support
- Background tasks and scheduled jobs
- Multi-platform SDK generation (TypeScript, Kotlin)
- OpenAPI documentation generation
Version: 5.x Main Branch: master
REST-First Methodology
⚠️ KEY INSIGHT: Before creating custom endpoints, ask if ModelRestEndpoints can handle it:
- Can REST create +
interceptCreatehandle validation? - Use for booking validation, conflict detection, subscription restrictions - Can REST modify +
updateRestrictionshandle field rules? - Use for phase transitions, state changes - Can REST query handle the read? - Client can compute complex views from multiple queries
- Can
postChangehandle side effects? - Use for cascade updates, notifications
Custom endpoints are ONLY needed for:
- Hardware integrations (door unlocks, IoT) - external systems dictate the interface
- External API callbacks (webhooks) - third party dictates the contract
- Truly atomic multi-model operations where eventual consistency isn't acceptable
Example - Reservation Booking (NO custom endpoint needed):
// All business logic in interceptCreate - no custom booking endpoint!
.interceptCreate { reservation ->
// 1. Conflict detection
val conflicts = table.find(condition {
(it.booth eq reservation.booth) and
(it.at lt reservation.endsAt) and
(it.endsAt gt reservation.at) and
it.cancelledAt.eq(null)
}).toList()
if (conflicts.isNotEmpty()) {
throw BadRequestException("Time slot conflicts with existing reservation")
}
// 2. Subscription restrictions
val restrictions = getRestrictions(reservation.user)
restrictions?.let { r ->
if (duration < r.minimumDuration) throw BadRequestException("Duration too short")
if (reservationsToday >= r.perDay) throw BadRequestException("Daily limit reached")
}
// 3. Set expiration for tentative booking
reservation.copy(expireAt = Clock.System.now() + 15.minutes)
}
// Background task handles expiration - no custom endpoint needed
val expireStale = path.path("scheduled/expire") bind ScheduledTask(frequency = 1.minutes) {
table.updateMany(
condition { it.expireAt.notNull and (it.expireAt lt now) and it.cancelledAt.eq(null) },
modification { it.cancelledAt assign now }
)
}
// Confirm booking = just remove expireAt via REST PATCH
// Cancel booking = just set cancelledAt via REST PATCH
Core Concepts
1. ServerBuilder Pattern
All Lightning Server applications use the ServerBuilder pattern:
object MyServer : ServerBuilder() {
// Settings
val database = setting("database", Database.Settings())
val cache = setting("cache", Cache.Settings())
// Endpoints
val hello = path.get bind HttpHandler {
HttpResponse.plainText("Hello World!")
}
}
2. Endpoint Definition
Endpoints use a fluent path-building syntax:
// Simple GET
val root = path.get bind HttpHandler { /* ... */ }
// With path parameter
val getUser = path.path("users").arg<String>("id").get bind HttpHandler { request ->
val id = request.path.arg1 // Type-safe access
// ...
}
// Multiple arguments
val getUserPost = path.path("users").arg<String>("userId")
.path("posts").arg<Int>("postId").get bind HttpHandler { request ->
val userId = request.path.arg1 // String
val postId = request.path.arg2 // Int
// ...
}
3. Typed Endpoints
Use typed endpoints for auto-documentation and SDK generation. There are two approaches:
Using .api() Helper (Recommended for simple cases)
val createPost = path.path("posts").post.api(
summary = "Create a blog post",
description = "Creates a new blog post with the provided data",
auth = noAuth, // or auth<User>()
errorCases = listOf(
LSError(http = 400, detail = "invalid-input", message = "Title required")
),
successCode = HttpStatus.Created,
implementation = { input: CreatePostRequest ->
// Type-safe implementation
CreatePostResponse(...)
}
)
Using ApiHttpHandler Directly (For custom paths or detail endpoints)
// Custom endpoint on ModelRestEndpoints detail path
val unlockDoor = rest.detailPath.path("unlock").post bind ApiHttpHandler(
summary = "Unlock Door",
description = "Unlocks the door for an active reservation",
auth = UserAuth.require(),
implementation = { _: Unit ->
// Access path parameters from parent paths
val reservationId = request.arg1 // From rest.detailPath
// Implementation is automatically suspend - can call suspend functions directly
val reservation = info.table().get(reservationId)
?: throw BadRequestException("Not found")
// Validate state
if (reservation.checkedInAt == null) {
throw BadRequestException("Not checked in")
}
// Call suspend functions directly
unifiClient.unlockDoor(reservation.lockId, durationSeconds = 60)
Unit
}
)
// With path parameter and input
val updateSettings = path.path("settings").arg<String>("key").post bind ApiHttpHandler(
summary = "Update Setting",
auth = UserAuth.require(),
implementation = { newValue: String ->
val settingKey = request.arg1 // Type-safe access to path arg
settingsService.update(settingKey, newValue)
}
)
Key ApiHttpHandler features:
- Implementation block is automatically suspend - no need to wrap in
runBlockingorcoroutineScope - Access path parameters via
request.arg1,request.arg2, etc. (type-safe based on.arg<T>()definitions) - Input type is the type parameter (
{ input: InputType -> ... }) - Return value becomes the response body
- Can call suspend functions directly (database queries, external API calls, etc.)
4. Database Operations
⚠️ IMPORTANT: Use ModelRestEndpoints for CRUD Operations
For standard CRUD operations, use the pre-built ModelRestEndpoints rather than manually creating database endpoints:
// Define your model with @GenerateDataClassPaths
@Serializable
@GenerateDataClassPaths
data class Post(
override val _id: Uuid = Uuid.random(),
val title: String,
val content: String,
val authorId: Uuid,
val createdAt: Instant = Clock.System.now()
) : HasId<Uuid>
// Set up ModelInfo with auth and permissions
val postInfo = database.modelInfo(
auth = UserAuth.require() or AuthRequirement.None,
permissions = {
val user = authOrNull?.fetch()
val isOwner = condition { it.authorId eqNn user?._id }
ModelPermissions(
create = if (user != null) Condition.Always else Condition.Never,
read = Condition.Always,
update = isOwner,
delete = isOwner
)
}
)
// Create REST endpoints automatically (provides list, get, create, update, delete, query)
val posts = path.path("posts").path("rest") module ModelRestEndpoints(postInfo)
// Optional: Add WebSocket updates for real-time changes
val postsWithWs = path.path("posts").path("rest") include
ModelRestEndpoints(postInfo) + ModelRestUpdatesWebsocket(postInfo)
Signal Composition and Lifecycle Hooks
ModelInfo supports powerful signal composition for validation, side effects, and data integrity:
val projectInfo = database.modelInfo(
auth = UserAuth.require(),
permissions = { permissions(this) },
signals = { table ->
table
// Validate changes before they're applied
.interceptChange(::interceptChange)
// Validate creates before insertion
.interceptCreates { validateProjects(it) }
// React after changes are persisted
.postChange { old, new -> handleProjectChange(old, new) }
// Clean up after deletion
.postDelete { deleteRelatedData(it) }
// Process images automatically
.interceptImagesForProcessing(
MediaPreviewOptions.CorrectOddFeatures,
MediaPreviewOptions(sizeInPixels = 200, type = MediaType.Image.JPEG)
) { it.avatar }
// Maintain denormalized fields
.denormalize(
Project_organization,
Project_organizationName,
organizationTable,
Organization_name,
null
)
}
)
REST Endpoint Lifecycle
⚠️ CRITICAL: Understanding the full lifecycle is essential for placing business logic in the right hooks.
Full Lifecycle for REST Operations
POST (Create) Lifecycle
When a client calls POST /model/rest:
1. Request arrives at ModelRestEndpoints.insert
↓
2. Authentication checked (via info.auth)
↓
3. info.table(auth) called → builds Table with all wrappers
↓
4. Table.interceptCreate runs
├─ Can modify the object being created
├─ Can throw exceptions to reject creation
├─ ⚠️ Denormalized fields NOT populated yet!
└─ Must fetch related records directly if needed
↓
5. Database insert occurs (actual write)
↓
6. Denormalization runs (.denormalize() calculations)
├─ Denormalized fields now populated
└─ Source records queried and values copied
↓
7. Permissions.create condition checked
├─ If condition fails, insertion is rolled back
└─ Returns ForbiddenException
↓
8. Table.postCreate runs
├─ Object is now in database with all fields
├─ Safe to trigger side effects
└─ Cannot stop creation (already committed)
↓
9. Response sent to client with created object
Example:
signals = { table ->
table
.interceptCreate { reservation ->
// 1. Validation - check conflicts
val conflicts = info.table().find(condition {
(it.booth eq reservation.booth) and
(it.at lt reservation.endsAt) and
(it.endsAt gt reservation.at) and
it.cancelledAt.eq(null)
}).toList()
if (conflicts.isNotEmpty()) {
throw BadRequestException("Time slot conflicts")
}
// 2. Business logic - check subscription limits
val subscription = getActiveSubscription(reservation.user)
subscription?.let {
val limit = getPlanLimit(it.plan)
val todayCount = countReservationsToday(reservation.user)
if (todayCount >= limit) {
throw BadRequestException("Daily limit reached")
}
}
// 3. Modify object - set expiration
reservation.copy(expireAt = Clock.System.now() + 15.minutes)
}
.postCreate { created ->
// 4. Side effects - send email notification
emailService.sendConfirmation(created.user, created)
}
}
PATCH (Update) Lifecycle
When a client calls PATCH /model/rest/{id}:
1. Request arrives with Modification<T>
↓
2. Authentication checked (via info.auth)
↓
3. info.table(auth) called → builds Table with all wrappers
↓
4. Table.interceptUpdate runs (if using interceptChangePerInstance)
├─ Has access to BOTH old and new values
├─ Can modify the Modification being applied
├─ Can throw exceptions to reject update
└─ Expensive - fetches existing record first
↓
5. Table.interceptChange / interceptModification runs
├─ Can validate or modify the Modification
├─ Can throw exceptions to reject update
└─ Cheaper - doesn't need existing record
↓
6. Permissions.updateRestrictions checked
├─ Field-level restrictions enforced
├─ e.g., it.author.cannotBeModified()
└─ Throws ForbiddenException if violated
↓
7. Database update occurs (actual write)
↓
8. Denormalization runs (if source fields changed)
├─ Updates denormalized fields
└─ May trigger cascade updates to other tables
↓
9. Permissions.update condition checked
├─ Checked against OLD value
├─ If condition fails, update is rolled back
└─ Returns ForbiddenException
↓
10. Table.postChange runs
├─ Has access to both old and new values
├─ Object already updated in database
├─ Safe to trigger side effects
└─ Cannot stop update (already committed)
↓
11. Response sent to client with updated object
Example:
permissions = {
val user = auth.fetch()
ModelPermissions(
// ...
update = condition { it.user eq user._id }, // Can only update your own
updateRestrictions = updateRestrictions {
it.user.cannotBeModified() // Field restriction
it.createdAt.cannotBeModified() // Field restriction
}
)
},
signals = { table ->
table
.interceptChange { mod ->
// Validate modifications before they're applied
mod.vet(Reservation_booth) { fieldMod ->
when (fieldMod) {
is Modification.Assign -> {
// Check if new booth is available
val newBooth = BoothEndpoints.info.table().get(fieldMod.value)
?: throw BadRequestException("Booth not found")
if (!newBooth.available) {
throw BadRequestException("Booth not available")
}
}
else -> {}
}
}
mod
}
.postChange { old, new ->
// Side effect - if booth changed, send notification
if (old.booth != new.booth) {
notificationService.send(new.user, "Booth changed")
}
}
}
Hook Reference Guide
interceptCreate / interceptCreates
When it runs: Before database insert, after auth check
What it can do:
- ✅ Modify the object being created (return modified copy)
- ✅ Throw exceptions to reject creation
- ✅ Validate business rules (uniqueness, conflicts, quotas)
- ✅ Set computed fields (expiration, defaults)
- ✅ Query database for validation
- ❌ Cannot access denormalized fields (not populated yet)
Use cases:
- Conflict detection (time slot conflicts, unique constraints)
- Subscription/quota validation (daily limits, plan restrictions)
- Setting expiration times (tentative bookings)
- Validating related records exist
- Enforcing business rules
Example:
.interceptCreate { reservation ->
// Validate: No conflicts
val conflicts = findConflicts(reservation)
if (conflicts.isNotEmpty()) throw BadRequestException("Conflict")
// Validate: Subscription limits
val subscription = getSubscription(reservation.user)
validateLimits(subscription, reservation)
// Modify: Set expiration
reservation.copy(expireAt = now() + 15.minutes)
}
interceptChange / interceptModification
When it runs: Before database update, after auth check, before updateRestrictions
What it can do:
- ✅ Modify the Modification being applied
- ✅ Throw exceptions to reject update
- ✅ Validate field changes with mod.vet()
- ✅ Query database for validation
- ❌ Cannot access old/new values (use interceptChangePerInstance for that)
Use cases:
- Field-level validation (enum transitions, format checks)
- Validating modifications before application
- Transforming modifications (normalize data)
- Enforcing state machine rules
Example:
.interceptChange { mod ->
mod.vet(Reservation_status) { fieldMod ->
when (fieldMod) {
is Modification.Assign -> {
// Validate status transitions
if (!isValidTransition(currentStatus, fieldMod.value)) {
throw BadRequestException("Invalid status transition")
}
}
else -> {}
}
}
mod
}
interceptChangePerInstance (expensive!)
When it runs: Before database update, fetches existing record first
What it can do:
- ✅ Access both old value and modification
- ✅ Modify the Modification based on old value
- ✅ Throw exceptions to reject update
- ⚠️ Expensive - requires extra database read
Use cases:
- Validation that requires old value
- State machine transitions
- Audit trail generation
Example:
.interceptChangePerInstance { old, mod ->
// Can access old value for validation
if (old.status == Status.Completed) {
throw BadRequestException("Cannot modify completed reservation")
}
mod
}
updateRestrictions (in ModelPermissions)
When it runs: After interceptChange, before database update
What it can do:
- ✅ Block specific fields from being modified
- ✅ Enforced automatically by framework
- ❌ Cannot throw custom exceptions (framework handles it)
Use cases:
- Prevent modification of immutable fields (author, createdAt)
- Enforce field-level access control
- Prevent tampering with computed fields
Example:
ModelPermissions(
// ...
updateRestrictions = updateRestrictions {
it.user.cannotBeModified() // User cannot change
it.createdAt.cannotBeModified() // Created timestamp locked
it.organizationName.cannotBeModified() // Denormalized field locked
}
)
postChange
When it runs: After successful database update, after denormalization
What it can do:
- ✅ Access both old and new values
- ✅ Trigger side effects (notifications, cascade updates)
- ✅ Update denormalized data in other tables
- ✅ Log changes for audit trail
- ❌ Cannot stop the update (already committed)
- ❌ Should not throw exceptions (may leave inconsistent state)
Use cases:
- Send notifications (email, push, WebSocket)
- Cascade updates to denormalized fields in other tables
- Trigger workflows based on changes
- Audit logging
- Cache invalidation
Example:
.postChange { old, new ->
// Cascade update denormalized fields
if (old.name != new.name) {
RelatedTable.updateMany(
condition { it.projectId eq new._id },
modification { it.projectName assign new.name }
)
}
// Send notifications
if (old.status != new.status) {
notificationService.send(new.user, "Status changed to ${new.status}")
}
}
postCreate
When it runs: After successful database insert, after denormalization
What it can do:
- ✅ Access the created object (with all fields populated)
- ✅ Trigger side effects (notifications, related record creation)
- ✅ Create related records
- ❌ Cannot stop the creation (already committed)
- ❌ Should not throw exceptions (may leave inconsistent state)
Use cases:
- Send welcome emails
- Create related records (default settings, initial data)
- Trigger external systems
- Analytics tracking
Example:
.postCreate { created ->
// Send confirmation email
emailService.sendWelcome(created.email)
// Create default settings
SettingsTable.insertOne(Settings(userId = created._id))
}
postDelete
When it runs: After successful database deletion
What it can do:
- ✅ Access the deleted object
- ✅ Clean up related records
- ✅ Trigger external cleanup
- ❌ Cannot stop the deletion (already committed)
- ❌ Should not throw exceptions (may leave inconsistent state)
Use cases:
- Delete related records (cascade delete)
- Clean up uploaded files
- Notify external systems
- Audit logging
Example:
.postDelete { deleted ->
// Clean up related records
RelatedTable.deleteMany(condition { it.projectId eq deleted._id })
// Delete uploaded files
deleted.avatarFile?.let { fileService.delete(it) }
}
Decision Tree: Which Hook to Use?
Need to validate before creation?
→ Use interceptCreate
Need to validate before update?
→ Use interceptChange (or interceptModification)
Need old value for validation?
→ Use interceptChangePerInstance (expensive!)
Need to prevent specific fields from being modified?
→ Use updateRestrictions in ModelPermissions
Need to send notifications after change?
→ Use postChange or postCreate
Need to cascade updates to other tables?
→ Use postChange
Need to clean up after deletion?
→ Use postDelete
Example: Complete Reservation System
Putting it all together for a complete reservation system:
val reservationInfo = database.modelInfo(
auth = UserAuth.require(),
permissions = {
val user = auth.fetch()
ModelPermissions(
create = condition { it.user eq user._id },
read = condition { it.user eq user._id },
update = condition { it.user eq user._id },
delete = condition { it.user eq user._id },
updateRestrictions = updateRestrictions {
it.user.cannotBeModified() // Can't change owner
it.createdAt.cannotBeModified() // Can't change timestamp
it.booth.cannotBeModified() // Can't change booth after creation
}
)
},
signals = { table ->
table
// 1. VALIDATION ON CREATE
.interceptCreate { reservation ->
// Check for time slot conflicts
val conflicts = info.table().find(condition {
(it.booth eq reservation.booth) and
(it.at lt reservation.endsAt) and
(it.endsAt gt reservation.at) and
it.cancelledAt.eq(null)
}).toList()
if (conflicts.isNotEmpty()) {
throw BadRequestException("Time slot conflicts with existing reservation")
}
// Check subscription limits (if subscription exists)
val subscription = findActiveSubscription(reservation.user, reservation.organization)
subscription?.let { sub ->
val restrictions = getPlanRestrictions(sub.plan)
val duration = reservation.endsAt - reservation.at
if (duration < restrictions.minimumDuration) {
throw BadRequestException("Duration too short")
}
if (duration > restrictions.maximumDuration) {
throw BadRequestException("Duration too long")
}
val todayCount = countReservationsToday(reservation.user, reservation.booth)
if (todayCount >= restrictions.perDay) {
throw BadRequestException("Daily limit reached")
}
}
// Set expiration for tentative booking
reservation.copy(expireAt = Clock.System.now() + 15.minutes)
}
// 2. VALIDATION ON UPDATE
.interceptChange { mod ->
// Validate status transitions
mod.vet(Reservation_status) { fieldMod ->
when (fieldMod) {
is Modification.Assign -> {
if (!isValidStatusTransition(fieldMod.value)) {
throw BadRequestException("Invalid status transition")
}
}
else -> {}
}
}
mod
}
// 3. SIDE EFFECTS AFTER CHANGE
.postChange { old, new ->
// Send notification if status changed
if (old.status != new.status) {
notificationService.send(
new.user,
"Reservation status changed to ${new.status}"
)
}
// If confirmed (expireAt cleared), send confirmation email
if (old.expireAt != null && new.expireAt == null) {
emailService.sendConfirmation(new.user, new)
}
}
// 4. SIDE EFFECTS AFTER CREATE
.postCreate { created ->
// Send tentative booking notification
emailService.sendTentativeBooking(created.user, created)
}
// 5. CLEANUP AFTER DELETE
.postDelete { deleted ->
// Cancel any related services
if (deleted.checkedInAt != null) {
serviceIntegration.releaseResources(deleted.booth)
}
}
}
)
Common Pitfalls
❌ Accessing denormalized fields in interceptCreate:
.interceptCreate { request ->
// ❌ WRONG - proposedBy is not set yet!
if (request.proposedBy == request.proposedTo) {
throw BadRequestException("Cannot propose to yourself")
}
// ✅ CORRECT - fetch source data directly
val fromReservation = ReservationTable.get(request.fromReservation)
?: throw BadRequestException("Not found")
if (fromReservation.user == toReservation.user) {
throw BadRequestException("Cannot propose to yourself")
}
}
❌ Throwing exceptions in postChange:
.postChange { old, new ->
// ❌ WRONG - may leave inconsistent state
if (new.invalidField) {
throw BadRequestException("Invalid")
}
// ✅ CORRECT - validate in interceptChange instead
}
❌ Forgetting updateRestrictions:
// ❌ WRONG - client can modify createdAt!
ModelPermissions(
update = condition { it.user eq user._id }
)
// ✅ CORRECT - lock immutable fields
ModelPermissions(
update = condition { it.user eq user._id },
updateRestrictions = updateRestrictions {
it.createdAt.cannotBeModified()
it.user.cannotBeModified()
}
)
⚠️ CRITICAL: Signal Hook Execution Order
Understanding the exact execution order is crucial for correct implementation:
- interceptCreates / interceptChange - Validation (can throw exceptions)
- ⚠️ Denormalized fields are NOT populated yet!
- Must fetch source records directly if needed
- Database write occurs - Record inserted/updated
- Denormalization updates -
.denormalize()calculations run, fields populated - postChange / postCreate / postDelete - Side effects (cascade updates, notifications)
Example showing timing:
.denormalize2(
BoothDividerOpenRequest_fromReservation,
ReservationEndpoints.info.table(),
DenormalizationCalculation(BoothDividerOpenRequest_proposedBy, Uuid.fromLongs(0, 0)) { it.user },
DenormalizationCalculation(BoothDividerOpenRequest_from, Instant.DISTANT_PAST) { it.at }
).interceptCreate { request ->
// ⚠️ request.proposedBy and request.from are NOT set yet!
// Denormalization hasn't run - still have default values
// ✅ Must fetch source directly:
val fromReservation = ReservationEndpoints.info.table().get(request.fromReservation)
?: throw BadRequestException("fromReservation not found")
val proposedBy = fromReservation.user // Get from source, not request.proposedBy
val timeRange = fromReservation.at .. fromReservation.endsAt
// Validate and potentially modify the request
val toReservation = findToReservation(request.toBooth, timeRange)
request.copy(toReservation = toReservation._id, proposedTo = toReservation.user)
}
Key patterns:
- interceptChange: Validate modifications before applying
- interceptCreates: Validate before insertion (e.g., check quotas, uniqueness, related records exist)
- Can modify the object being created by returning a modified copy
- Denormalized fields not yet populated - fetch source data if needed
- postChange: Cascade updates to denormalized data
- postDelete: Clean up related records
- denormalize: Auto-maintain denormalized fields (updates automatically on source changes)
Optional Validation Pattern:
A common pattern is to only enforce restrictions when certain conditions are met (e.g., user has a subscription):
.interceptCreate { reservation ->
// Get booth and location info
val booth = BoothEndpoints.info.table().get(reservation.booth)
?: throw BadRequestException("Booth not found")
val location = LocationEndpoints.info.table().get(booth.location)
?: throw BadRequestException("Location not found")
// Get restrictions (might be null if no subscription)
val subscription = findActiveSubscription(reservation.user, booth.organization, booth.location)
val restrictions = subscription?.let { sub ->
getPlanRestrictions(sub.plan, booth.boothType)
}
// Only validate if restrictions exist
restrictions?.let { restr ->
// Validate duration
val duration = reservation.endsAt - reservation.at
if (duration < restr.minimumDuration) {
throw BadRequestException("Duration too short")
}
if (duration > restr.maximumDuration) {
throw BadRequestException("Duration too long")
}
// Validate per-day limit
val dayStart = reservation.at.toLocalDateTime(location.timeZone).date.atStartOfDayIn(location.timeZone)
val reservationsToday = info.table().find(
condition {
(it.user eq reservation.user) and
(it.booth eq reservation.booth) and
(it.at gte dayStart) and
it.cancelledAt.eq(null)
}
).toList().size
if (reservationsToday >= restr.perDay) {
throw BadRequestException("Daily limit reached")
}
}
reservation
}
This gives you:
GET /posts/rest- List with pagination, sorting, filteringGET /posts/rest/{id}- Get by IDPOST /posts/rest- CreatePUT /posts/rest/{id}- UpdateDELETE /posts/rest/{id}- DeletePOST /posts/rest/query- Advanced queryingWS /posts/rest/watch- Real-time updates (if WebSocket added)
Manual Database Operations (use only when needed)
Use low-level database operations for custom business logic beyond simple CRUD:
val posts = database().table<Post>()
// Insert
posts.insertOne(Post(title = "Hello", content = "World"))
// Query
posts.find(condition { it.title eq "Hello" }).toList()
// Update
posts.updateOne(
condition { it._id eq id },
modification { it.title assign "Updated" }
)
// Delete
posts.deleteMany(condition { it.authorId eq userId })
// Complex queries
posts.find(
condition = condition {
(it.title.contains("Kotlin")) and (it.createdAt gt yesterday)
},
orderBy = listOf(SortPart(Post.path.createdAt, false)),
skip = page * pageSize,
limit = pageSize
).toList()
Use manual operations when you need:
- Custom business logic beyond CRUD
- Complex queries not supported by ModelRestEndpoints
- Special validation or transformation logic
- Aggregations or computed fields
Modification Validation with mod.vet()
Validate modifications before they're applied to the database using mod.vet():
fun interceptChange(mod: Modification<Project>): Modification<Project> {
// Validate specific field modifications
mod.vet(Project_projectTags) { fieldMod ->
when (fieldMod) {
is Modification.Assign -> {
if (fieldMod.value.any { it != it.trim() })
throw BadRequestException("All tags must be trimmed")
if (fieldMod.value.any { it != it.lowercase() })
throw BadRequestException("All tags must be lowercase")
}
is Modification.SetAppend -> {
if (fieldMod.items.any { it != it.trim() })
throw BadRequestException("All tags must be trimmed")
if (fieldMod.items.any { it != it.lowercase() })
throw BadRequestException("All tags must be lowercase")
}
else -> {}
}
}
return mod
}
// Use in signals
signals = { table ->
table.interceptChange(::interceptChange)
}
Common Modification types:
Modification.Assign- Setting a value directlyModification.SetAppend/Modification.SetRemove- Modifying setsModification.ListAppend/Modification.ListRemove- Modifying listsModification.Increment/Modification.Decrement- Math operations
Cascade Updates in postChange:
context(runtime: ServerRuntime)
suspend fun postChange(old: Project, new: Project) {
// Update denormalized fields across tables
if (old.name != new.name) {
Server.tasks.info.baseTable()
.updateManyIgnoringResult(
condition { it.project eq new._id },
modification { it.projectName assign new.name }
)
Server.timeEntries.info.baseTable()
.updateManyIgnoringResult(
condition { it.project eq new._id },
modification { it.projectName assign new.name }
)
}
}
context(runtime: ServerRuntime)
suspend fun postDelete(project: Project) {
// Clean up related records
Server.tasks.info.baseTable()
.deleteManyIgnoringOld(condition { it.project eq project._id })
}
Best practices:
- Use
interceptChangefor validation (can throw exceptions to prevent changes) - Use
postChangefor side effects (cascade updates, notifications, logging) - Use
postDeletefor cleanup (delete related records, notify systems) - Use
updateManyIgnoringResultfor performance (skips fetching updated records) - Use
deleteManyIgnoringOldfor performance (skips fetching deleted records)
5. Authentication
Define a PrincipalType for your user model:
object UserAuth: PrincipalType<User, Uuid> {
override val idSerializer = Uuid.serializer()
override val subjectSerializer = User.serializer()
override val name = "User"
context(server: ServerRuntime)
override suspend fun fetch(id: Uuid): User =
database().table<User>().get(id) ?: throw NotFoundException()
}
Set up ModelInfo with permissions:
val userInfo: ModelInfo<User?, User, Uuid> = database.modelInfo(
auth = UserAuth.require() or AuthRequirement.None,
permissions = {
val user = authOrNull?.fetch()
val self = condition { it._id eqNn user?._id }
val admin = if (user?.isSuperUser == true) Condition.Always else Condition.Never
ModelPermissions(
create = Condition.Never,
read = Condition.Always,
update = self or admin,
delete = admin
)
}
)
Configure proof methods:
val pins = PinHandler(cache, "pins")
val proofEmail = path.path("proof").path("email") module
EmailProofEndpoints(pins, email, { to, pin ->
Email(subject = "Login Code", to = listOf(EmailAddressWithName(to)),
plainText = "Your PIN is $pin")
})
val proofPassword = path.path("proof").path("password") module
PasswordProofEndpoints(database, cache)
Set up AuthEndpoints:
val auth = path.path("auth") module object: AuthEndpoints<User, Uuid>(
principal = UserAuth,
database = database
) {
context(server: ServerRuntime)
override suspend fun requiredProofStrengthFor(subject: User): Int = 5
context(server: ServerRuntime)
override suspend fun sessionExpiration(subject: User): Instant? = null
}
Advanced: Auth Caching with AuthCacheKey
Cache expensive authentication-derived data to avoid repeated database queries:
object UserAuth : PrincipalType<User, Uuid> {
// ... other fields ...
// Define what to pre-cache on session creation
override val precache: List<AuthCacheKey<User, *>> = listOf(IsSuperUserCache, MembershipCache)
// Simple cache example
object IsSuperUserCache : AuthCacheKey<User, Boolean> {
override val id: String = "super-user"
override val serializer: KSerializer<Boolean> = Boolean.serializer()
override val expireAfter: Duration = 5.minutes
context(_: ServerRuntime)
override suspend fun calculate(input: Authentication<User>): Boolean =
input.fetch().isSuperUser
// Extension functions for easy access
context(_: ServerRuntime) suspend fun Authentication<User>.isSuperUser() = get(IsSuperUserCache)
context(_: ServerRuntime) suspend fun AuthAccess<User>.isSuperUser() = auth.isSuperUser()
}
// Complex cache with related data
object MembershipCache : AuthCacheKey<User, Set<ActiveMembership>> {
override val id: String = "memberships"
override val serializer = SetSerializer(ActiveMembership.serializer())
override val expireAfter = 5.minutes
context(_: ServerRuntime)
override suspend fun calculate(input: Authentication<User>): Set<ActiveMembership> {
val memberships = input.fetch().memberships
val activeOrgs = OrganizationTable.getMany(memberships.map { it.organization })
.filter { it.subscriptionActive }
.map { it._id }
.toSet()
return memberships.map {
ActiveMembership(it, it.organization in activeOrgs)
}.toSet()
}
}
// Derived caches (transform existing cache data efficiently)
private fun <T, R : Any> AuthCacheKey<User, Set<T>>.derive(
id: String,
transform: (T) -> R?
): AuthCacheKey<User, Set<R>> = object : AuthCacheKey<User, Set<R>> {
override val id = id
override val serializer = SetSerializer(serializerOrContextual<R>())
override val expireAfter = this@derive.expireAfter
override val localOnly = true // Not stored on token
context(_: ServerRuntime)
override suspend fun calculate(input: Authentication<User>) =
input[this@derive].mapNotNullTo(HashSet(), transform)
}
// Chain derived caches
val memberships = MembershipCache.derive("memberships-only") { it.membership }
val activeMemberships = MembershipCache.derive("active") { it.takeIf { it.active }?.membership }
val organizations = memberships.derive("orgs") { it.organization }
val activeOrganizations = activeMemberships.derive("active-orgs") { it.organization }
// Convenience extensions
context(_: ServerRuntime) suspend fun AuthAccess<User>.activeOrganizations() = auth[activeOrganizations]
}
Use in permissions:
permissions = {
if (auth.isSuperUser()) return@permissions ModelPermissions.allowAll()
val orgs = auth.activeOrganizations()
val orgCondition = condition { it.organization inside orgs }
ModelPermissions(
create = orgCondition,
read = orgCondition,
update = orgCondition,
delete = orgCondition
)
}
Key benefits:
- Pre-cached values stored on session token (fast access)
- Derived caches avoid redundant database queries
localOnlycaches save token space (computed on-demand)- Automatic invalidation on cache expiry
Auto-User-Creation Pattern for Signup Flows
⚠️ IMPORTANT: For email-based authentication, you typically don't need custom signup endpoints. Users are automatically created on first login via fetchByProperty.
Automatic User Creation on First Login:
object UserAuth : PrincipalType<User, Uuid> {
// ... other fields ...
context(server: ServerRuntime)
override suspend fun fetchByProperty(property: String, value: String): User? = when (property) {
"email" -> UserEndpoints.info.table()
.run {
// Automatically creates user if doesn't exist
findOne(condition { it.email eq value.toEmailAddress() })
?: insertOne(User(email = value.toEmailAddress()))
}
else -> super.fetchByProperty(property, value)
}
}
This means:
- When a user authenticates via email for the first time, a User record is created automatically
- No need for dedicated "signup" endpoints - just use existing EmailProofEndpoints
- User is created with minimal fields (just email), additional profile info added later
Progressive Profile Completion Pattern:
After auto-creation, guide users to complete their profile:
Backend:
@Serializable
data class User(
override val _id: Uuid = Uuid.random(),
val email: EmailAddress,
val name: String = "No Name Specified", // Default value
val phone: String? = null, // Optional
val emergencyContact: String? = null, // Optional
val role: UserRole = UserRole.User,
) : HasId<Uuid>
Frontend (KiteUI):
// After successful authentication, check profile completion
reactive {
val session = currentSession()
if(session == null) {
pageNavigator.reset(LandingPage())
} else {
val user = session.api.userAuth.getSelf()
val needsProfileCompletion = user.name == "No Name Specified" ||
user.phone == null ||
user.emergencyContact == null
if (needsProfileCompletion) {
pageNavigator.reset(ProfileCompletionScreen(user.email.raw))
}
}
}
Updating User Profile:
// In ProfileCompletionScreen
onClick {
launch {
val session = currentSession.awaitNotNull()
// Modify user with ModelCache helper
session.users[session.userId].modify(modification {
it.name assign fullName.value
it.phone assign phone.value
it.emergencyContact assign emergencyContact.value
})
pageNavigator.reset(HomePage())
}
}
Key imports for modification:
import com.lightningkite.lightningdb.modification
import com.lightningkite.lskiteuistarter.name // Auto-generated
import com.lightningkite.lskiteuistarter.phone // Auto-generated
import com.lightningkite.lskiteuistarter.emergencyContact // Auto-generated
Why This Pattern Works:
- ✅ No redundant signup endpoints (reuse existing auth)
- ✅ Users can authenticate immediately (frictionless onboarding)
- ✅ Profile completed progressively (better UX)
- ✅ Standard REST endpoints for updates (consistent API)
6. File Handling
val files = setting("files", PublicFileSystem.Settings())
// Upload (early binding)
val uploadEarly = path.path("upload") module
UploadEarlyEndpoint(files, database, Runtime.Constant(listOf()))
// Get signed URL
val getFile = path.path("files").arg<String>("path").get bind HttpHandler {
val filePath = it.arg1
val fileRef = files().root.then(filePath)
HttpResponse.plainText(fileRef.signedUrl)
}
7. WebSockets
val topic = path.path("topic").topic(Message.serializer())
val socket = path.path("ws") bind WebSocketHandler(
willConnect = { Uuid.random().toString() },
didConnect = {
subscribe(topic)
send(WelcomeMessage())
},
messageFromClient = {
topic.send(Message(currentState, it.content))
},
topicHandlers = {
topic bind { send(it.value) }
},
disconnect = {
println("Disconnected: $currentState")
}
)
8. Background Tasks
// Define task
val emailTask = path.path("tasks").path("email") bind Task { input: EmailRequest ->
println("Sending email to ${input.to}")
delay(1000)
email().send(Email(subject = input.subject, to = listOf(EmailAddressWithName(input.to)),
plainText = input.body))
}
// Invoke task
val sendEmail = path.path("send-email").post bind HttpHandler { request ->
emailTask.invoke(EmailRequest(request.body!!.text()))
HttpResponse.plainText("Email queued")
}
// Scheduled task
val cleanup = path.path("scheduled-cleanup") bind ScheduledTask(
frequency = 1.hours
) {
println("Running cleanup...")
database().table<OldData>().deleteMany(condition {
it.createdAt lt Clock.System.now() - 30.days
})
}
// Tentative reservation expiration pattern
val expireStaleReservations = path.path("scheduled").path("expire-reservations") bind ScheduledTask(
frequency = 1.minutes
) {
val now = Clock.System.now()
val expiredCount = ReservationEndpoints.info.table().updateMany(
condition {
// Find tentative (has expireAt) reservations that have expired
it.expireAt.notNull and
(it.expireAt lt now) and
it.cancelledAt.eq(null)
},
modification {
it.cancelledAt assign now // Mark as cancelled
}
)
if (expiredCount > 0) {
println("Expired $expiredCount stale tentative reservations")
}
}
9. Caching
val cache = setting("cache", Cache.Settings())
// Set with expiration
cache().set("key", "value", expire = 5.minutes)
// Get
val value = cache().get<String>("key")
// Remove
cache().remove("key")
// Cache-aside pattern
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)
cache().set("data:$id", fresh, expire = 10.minutes)
return fresh
}
9.5. Migrations and Startup Tasks
Idempotent Migrations with doOnce:
context(_: ServerRuntime)
suspend fun runMigrations() {
// doOnce ensures migration only runs once, tracked in database
doOnce("migrate-categories", database) {
logging("migrate-categories") {
migrateCategories()
}
}
doOnce("migrate-icons", database) { migrateIcons() }
}
context(_: ServerRuntime)
suspend fun migrateCategories() {
val projects = database().table<Project>().all().toList()
for (project in projects) {
database().table<Project>().updateOne(
condition { it._id eq project._id },
modification { it.categories assign project.legacyCategories.toSet() }
)
}
}
Startup Initialization:
// In Server object - runs once on server startup
init {
path.path("setUpAdmins") bind startupOnce(database) {
defaultData() // Creates default admin users, organizations, etc.
}
}
context(_: ServerRuntime)
suspend fun defaultData() {
val adminUser = database().table<User>().insertOne(
User(email = "admin@example.com", isSuperUser = true)
)
database().table<Organization>().insertOne(
Organization(name = "Default Org")
)
}
Benefits:
doOncetracks completion in database - safe for restarts- Runs automatically on server startup
- Can be triggered manually via endpoint or CLI
- Idempotent - safe to call multiple times
10. Testing
Practical Testing Patterns from Real Projects:
class ServerTest {
init {
TestSettings // Initialize test database, settings
}
@Test
fun basicTest(): Unit = runBlocking {
// Direct table access for test setup
val org = Server.organizations.info.table()
.insertOne(Organization(name = "Test"))!!
val user = Server.users.info.table()
.insertOne(User(
email = "${Random.nextInt()}@test.com",
memberships = setOf(Membership(org._id, UserRole.Owner))
))!!
val project = Server.projects.info.table()
.insertOne(Project(
name = "Test Project",
organization = org._id
))!!
// Test endpoint with .test() helper
val result = Server.projects.someEndpoint.test(user, ProjectInput(...))
// Verify denormalized fields work correctly
assertEquals("Test Project", result.name)
assertEquals(org.name, result.organizationName)
}
@Test
fun testFcmTokenRegistration(): Unit = runBlocking {
val user = createTestUser()
Server.fcmTokens.registerEndpoint.test(user, "some-fcm-token")
assertEquals(user._id, Server.fcmTokens.info.table().get("some-fcm-token")!!.user)
}
}
⚠️ CRITICAL: Build Server Once Per Test Suite
When writing tests, ensure Server.build() is only called once across all tests to avoid DuplicateRegistrationError. Create a shared TestHelper:
// TestHelper.kt - shared across all test files
object TestHelper {
val testRunner by lazy { TestRunner(Server.build()) }
}
// In your test file
class ServerTest {
init {
JsonFileDatabase // Ensure mock implementations are loaded
}
@Test
fun testEndpoint() = runBlocking {
with(TestHelper.testRunner) {
val response = Server.someEndpoint.test()
assertEquals("expected", response.body!!.text())
}
}
}
Test Method Signatures
For basic HttpHandler endpoints:
// No path args
Server.endpoint.test(
queryParameters = QueryParameters(listOf("key" to "value")),
body = TypedData.text("content", MediaType.Text.Plain)
)
// With path args
Server.endpoint.test(
"pathArg1",
42, // pathArg2
queryParameters = QueryParameters.EMPTY
)
For ApiHttpHandler endpoints:
// No path args
Server.typedEndpoint.test(auth = null, input = RequestData(...))
// With path args
Server.typedEndpoint.test("pathArg", auth = null, input = RequestData(...))
Common Testing Pitfalls
⚠️ Duplicate UploadEarlyEndpoint Declarations
If you create multiple instances of UploadEarlyEndpoint (e.g., in different modules or endpoints), they will have conflicting declarations for how ServerFile is serialized. This causes runtime serialization errors that manifest as 500 Internal Server Error responses in tests, even though the code compiles successfully.
Solution: Only instantiate UploadEarlyEndpoint once in your server definition:
object Server : ServerBuilder() {
// ✅ Good - single instance
val uploadEarly = path.path("upload") module
UploadEarlyEndpoint(files, database, Runtime.Constant(listOf()))
// ❌ Bad - creates duplicate with conflicting ServerFile serialization
// val anotherUpload = path.path("upload2") module
// UploadEarlyEndpoint(files, database, Runtime.Constant(listOf()))
}
If you need multiple upload endpoints, reuse the same UploadEarlyEndpoint instance or use different endpoint patterns.
Testing Infrastructure Setup
Setting Up a Complete Testing Environment:
When setting up testing infrastructure for multi-project development, follow these patterns to avoid conflicts and streamline workflows:
Port Configuration
Pattern: Use isolated ports for each project to avoid conflicts. Document ports in testing configuration files.
// testing/settings.testing.json
{
"general": {
"publicUrl": "http://localhost:8082", // Backend port
"wsUrl": "ws://localhost:8082",
"debug": true
},
"ktorRunConfig": {
"host": "0.0.0.0",
"port": 8082 // Backend port
}
}
// apps/vite.config.mjs (for frontend)
export default {
server: {
port: 8942, // Frontend port
proxy: {
'/api': {
target: 'http://localhost:8082', // Backend port
ws: true
}
}
}
}
Shell Scripts:
- Update all testing scripts with correct ports
- Create start-backend.sh, start-frontend.sh, stop-all.sh, start-all.sh
- Document in testing/README.md
Debug Admin Token Feature
Pattern: Auto-generate admin session token when debug mode is enabled for testing.
import com.lightningkite.lightningserver.definition.generalSettings
import kotlin.uuid.Uuid
object Server : ServerBuilder() {
// ... other code ...
// Debug admin token - prints on server startup when debug=true
val debugAdminToken = path.path("debug-admin-token") bind StartupTask {
if (generalSettings().debug) {
// Create session for a specific admin user (ID: 0L, 10L)
val token = UserAuth.session.createSession(Uuid.fromLongs(0L, 10L)).second
println("Admin token: '$token'")
}
}
}
Key points:
- Import is
com.lightningkite.lightningserver.definition.generalSettings, NOTsettings.generalSettings - Use
StartupTaskto run code once on server startup - Token is printed to console for easy capture and use in testing
Testing workflow:
- Start backend server
- Script captures printed token from logs (e.g.,
grep "Admin token:") - Save to
.admin-tokenfile - Browser testing script injects token into localStorage
Custom Endpoints on ModelRestEndpoints
Pattern: Add custom endpoints to the REST path structure using bind ApiHttpHandler.
object PendingInputEndpoints : ServerBuilder() {
val info = Server.database.modelInfo<User, PendingInput, Uuid>(...)
val rest = path include ModelRestEndpoints(info)
// Custom endpoint: POST /pendingInput/respond
val respond = path.path("respond").post bind ApiHttpHandler(
summary = "Respond to Input",
description = "Provides a response to a pending input request",
auth = UserAuth.require(),
implementation = { request: RespondInputRequest ->
// Validation
val input = info.table().get(request.inputId)
?: throw NotFoundException("Input request not found")
// Security check
val isAdmin = auth.userRole() >= UserRole.Admin
if (input.ownerId != auth.id && !isAdmin) {
throw ForbiddenException("You do not own this input request")
}
// Business logic
if (input.status != InputStatus.PENDING) {
throw BadRequestException("Input already resolved")
}
// Update database
info.table().updateOneById(request.inputId, modification<PendingInput> {
it.status assign InputStatus.RESOLVED
it.response assign request.response
it.resolvedAt assign Clock.System.now()
})
Unit
}
)
}
Request data class in shared models:
@Serializable
data class RespondInputRequest(
val inputId: Uuid,
val response: String
)
After adding custom endpoint:
- Regenerate SDK:
./gradlew :server:generateSdk - Generated method will be available in frontend SDK (e.g.,
api.pendingInput.respondToInput(...)) - Frontend can call the endpoint through type-safe SDK
Common Pitfalls
❌ Wrong generalSettings import:
// ❌ WRONG - compile error
import com.lightningkite.lightningserver.settings.generalSettings
// ✅ CORRECT
import com.lightningkite.lightningserver.definition.generalSettings
❌ Frontend MJS reference mismatch:
When using Kotlin/JS with Vite, the generated JavaScript bundle name is based on the project name, not the template name.
<!-- index.html -->
<!-- ❌ WRONG - if project name is "claude-coordinator" -->
<script src="/ls-kiteui-starter-apps.mjs" type="module"></script>
<!-- ✅ CORRECT - must match project name from settings.gradle.kts -->
<script src="/claude-coordinator-apps.mjs" type="module"></script>
Debugging frontend blank page:
- Check browser console for "Failed to load url /xxx-apps.mjs" errors
- Verify project name in
settings.gradle.kts(rootProject.name) - Update index.html to reference
/${projectName}-apps.mjs - Generated MJS file will match the rootProject.name
❌ Calling non-existent SDK methods:
After adding custom endpoints, you MUST regenerate the SDK before using them in the frontend:
# Always regenerate after changing server endpoints
./gradlew :server:generateSdk
SDK generation creates type-safe methods in the frontend API client. Without regeneration, you'll get compilation errors when trying to call new endpoints.
Browser Testing with Chrome MCP
For end-to-end testing, use Claude's Chrome MCP tools rather than separate Playwright/Selenium setups:
# testing/prepare-browser-test.sh
#!/bin/bash
# Start backend
./testing/start-backend.sh
# Wait for backend
while ! curl -s http://localhost:8082 > /dev/null; do
sleep 1
done
# Capture admin token from logs
TOKEN=$(grep "Admin token:" server.log | cut -d"'" -f2)
echo "$TOKEN" > testing/.admin-token
# Start frontend
./testing/start-frontend.sh
echo "Ready for browser testing at http://localhost:8942"
Testing workflow:
- Run prepare script to start servers and capture token
- Use Chrome MCP tools to navigate and test
- Inject token into localStorage for authenticated testing
- Take screenshots to verify UI rendering
Common Patterns
REST PATCH with Modifications
⚠️ CRITICAL: REST PATCH endpoints ALWAYS use Modification<T> wrapper, never raw values.
To update a field via PATCH:
# Set a field to a value
PATCH /model/rest/{id}
{"fieldName": {"Assign": "newValue"}}
# Set an optional field to null (clear it)
PATCH /model/rest/{id}
{"fieldName": {"Assign": null}}
# Increment a number
PATCH /model/rest/{id}
{"count": {"Increment": 5}}
# Multiple fields at once
PATCH /model/rest/{id}
{
"Chain": [
{"firstName": {"Assign": "John"}},
{"age": {"Increment": 1}}
]
}
Common Use Cases:
Confirm a tentative reservation (clear expireAt):
PATCH /reservation/rest/{id}
{"expireAt": {"Assign": null}}
Cancel a reservation (set cancelledAt):
PATCH /reservation/rest/{id}
{"cancelledAt": {"Assign": "2025-12-20T00:00:00Z"}}
Check in to a session (set checkedInAt):
PATCH /reservation/rest/{id}
{"checkedInAt": {"Assign": "2025-12-20T14:00:00Z"}}
See full Modification types documentation:
/Users/jivie/Projects/lightning-server/docs/use-as-client.md- Lines 127-205Assign,Increment,ListAppend,SetAppend,Chain, etc.
Why this matters:
- ❌
{"expireAt": null}→ JSON parse error - ✅
{"expireAt": {"Assign": null}}→ Works correctly - Error message is cryptic because it comes from JSON parser itself
- Always wrap field updates in Modification objects
Organizing Endpoints
Group related endpoints into ServerBuilder objects:
object ApiEndpoints : ServerBuilder() {
val posts = path.path("posts") include PostsEndpoints
val comments = path.path("comments") include CommentsEndpoints
}
object Server : ServerBuilder() {
val api = path.path("api") include ApiEndpoints
}
Error Handling
Use standard exceptions:
throw BadRequestException("Invalid input")
throw NotFoundException("Resource not found")
throw UnauthorizedException("Auth required")
throw ForbiddenException("Access denied")
Accessing Services
Services are accessed through settings:
object Server : ServerBuilder() {
val database = setting("database", Database.Settings())
val endpoint = path.get bind HttpHandler {
val db = database()
// Use db...
}
}
Path Reference Pattern
Always store endpoint references for testing and internal calls:
object Server : ServerBuilder() {
val createPost = path.path("posts").post bind ApiHttpHandler { ... }
val getPost = path.path("posts").arg<Uuid>("id").get bind ApiHttpHandler { ... }
// Can reference: Server.createPost, Server.getPost
}
Troubleshooting
Common Import Errors
When you see "Unresolved reference" errors for database operations or datetime utilities, you're likely missing imports:
// Database operation imports
import com.lightningkite.services.database.get
import com.lightningkite.services.database.find
import com.lightningkite.services.database.lt
import com.lightningkite.services.database.lte
import com.lightningkite.services.database.gte
import com.lightningkite.services.database.eq
import com.lightningkite.services.database.neq
import com.lightningkite.services.database.condition
import com.lightningkite.services.database.modification
// DateTime operation imports (kotlinx.datetime, not kotlin.time)
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.minus
// Flow operations
import kotlinx.coroutines.flow.toList
Common mistakes:
- Using
kotlin.time.DateTimeUnitinstead ofkotlinx.datetime.DateTimeUnit - Using
.valueonDayOfWeek(doesn't exist) - use.ordinalinstead (0-6 where Monday=0) - Forgetting to import database query operators like
lt,gte, etc.
Type Inference Issues with DataClassPath
If you see "Cannot infer type parameter 'ROOT'" errors, ensure you're using generated path constants correctly:
// ✅ Correct - using generated path constant
condition { it.user eq userId }
// ❌ Wrong - missing context or incorrect path usage
condition { User_email eq "test@example.com" } // Need it.email, not User_email
Denormalization Not Working
If denormalized fields aren't updating:
- Check that denormalize is in the
signalsblock - Verify the source table and field are correct
- Remember: fields aren't populated in
interceptCreate- they update after insert
Build System
Lightning Server uses Gradle with Kotlin Multiplatform:
Common Commands
# Build all modules
./gradlew build
# Run tests
./gradlew check
# Run demo server
./gradlew :demo:run --args="serve"
# Generate SDK
./gradlew :demo:run --args="sdk"
# Publish to local Maven
./gradlew publishToMavenLocal
Module Structure
Projects typically have paired modules:
module- JVM-only code (server implementation)module-shared- Multiplatform code (shared models, DTOs)
Deployment
Engines
Lightning Server supports multiple engines:
engine-local- For unit testingengine-ktor- Ktor HTTP server (dev/prod)engine-netty- Netty HTTP serverengine-jdk-server- Pure JDK HTTP serverengine-aws-serverless- AWS Lambda with Terraform generation
AWS Deployment
The AWS engine auto-generates Terraform:
fun main() {
val built = Server.build()
AwsHandler(built).apply {
settings.loadFromFile(KFile("settings.json"))
// Generates terraform/ directory
}
}
Settings Management
First run generates settings.json:
{
"database": {
"url": "mongodb://localhost:27017/mydb"
},
"cache": {
"url": "redis://localhost:6379"
}
}
Best Practices
- Use ModelRestEndpoints for CRUD - Don't manually create database CRUD endpoints; use ModelRestEndpoints
- Settings File Works Out-of-Box - Generated settings should allow immediate running
- Use Service Abstractions - Don't depend on specific implementations
- Test with Mocks - Use JsonFileDatabase, RAM cache for tests
- Store Endpoint References - Keep constants for all endpoints
- Group Endpoints Logically - Use ServerBuilder objects
- Type Safety - Use @GenerateDataClassPaths on all database models
- Document Typed Endpoints - Add summaries and descriptions
- Read This Skill First - When working on Lightning Server files, read this skill to understand patterns
Anti-Patterns
❌ Don't manually create CRUD endpoints - Use ModelRestEndpoints instead
❌ Don't create multiple UploadEarlyEndpoint instances - Causes ServerFile serialization conflicts
❌ Don't call Server.build() multiple times in tests - Use shared TestHelper with lazy initialization
❌ Don't assume denormalized fields are set in interceptCreate - They're not populated yet!
❌ Don't access database implementations directly
❌ Don't hardcode configuration
❌ Don't skip endpoint reference storage
❌ Don't forget @GenerateDataClassPaths
❌ Don't use plain HttpHandler for APIs (use typed endpoints)
❌ Don't test against real services
❌ Don't write manual list/get/create/update/delete endpoints when ModelRestEndpoints can do it
❌ Don't use kotlin.time.DateTimeUnit - use kotlinx.datetime.DateTimeUnit
Key Files to Reference
demo/src/main/kotlin/.../Server.kt- Comprehensive exampledocs/setup.md- Project setupdocs/endpoints.md- Endpoint patternsdocs/typed-endpoints.md- Typed API docsdocs/database.md- Database usagedocs/authentication.md- Auth setup
Getting Help
When stuck:
- Read this skill - Most common patterns are documented here
- Check the demo server for examples
- Review relevant docs in
/docs - Look at existing endpoint implementations
- Check test files for usage patterns
- Examine the CLAUDE.md file for project-specific guidance
Usage
IMPORTANT: Invoke this skill proactively when you:
- Open any file with
import com.lightningkite.lightningserver.* - Work on Lightning Server endpoints
- Set up authentication
- Work with databases
- Handle file uploads
- Implement WebSockets
- Create background tasks
- Write tests
- Deploy to AWS
- See compilation errors related to Lightning Server
Simply say: "Help me build a Lightning Server endpoint" or "How do I set up auth in Lightning Server?"