From d1b7d87daa1139b3ad5e9990d47b9d6ed9f137ac Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 03:05:34 -0600 Subject: [PATCH 01/11] =?UTF-8?q?Docs:=20rewrite=20files=20&=20websockets?= =?UTF-8?q?=20guides,=20add=20v4=E2=86=92v5=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite docs/files.md to the v5 API: single UploadEarlyEndpoint with the upload-then-reference flow, jail/ready scanner quarantine, ServerFile in models, signed URLs, and the one-instance-per-server footgun. Replace the v4 HttpContent/FileObject examples with the TypedData/PublicFileSystem API and drop the removed Azure backend. Replace the docs/websockets.md TODO stub with real docs: WebSocketHandler lifecycle, pub/sub topics, MultiplexWebSocketHandler, typed ApiWebsocketHandler, and local vs AWS execution models. Add docs/migration-v4-to-v5.md covering the service-abstractions extraction, ServerBuilder / definition-vs-runtime split, dependency and import moves, and common pitfalls, deferring KiteUI client details to the ls5-kui7-migration skill. Co-Authored-By: Claude Fable 5 (cherry picked from commit 3c6c27e8d870b3d8bde32a4d28e5bba9975cbe60) (cherry picked from commit 5bd3d6f4d670dc33decad599f108c997dd8f539f) --- docs/files.md | 218 ++++++++++++++++++++++++------------- docs/migration-v4-to-v5.md | 200 ++++++++++++++++++++++++++++++++++ docs/websockets.md | 156 +++++++++++++++++++++++++- 3 files changed, 495 insertions(+), 79 deletions(-) create mode 100644 docs/migration-v4-to-v5.md 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/migration-v4-to-v5.md b/docs/migration-v4-to-v5.md new file mode 100644 index 000000000..ed78c9811 --- /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 = database().table().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/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. From faf3fc910c586f5921f28679b9edb0d35e9096a4 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Thu, 16 Jul 2026 08:40:57 -0600 Subject: [PATCH 02/11] =?UTF-8?q?Docs:=20add=20v5.1=E2=86=92v5.2=20migrati?= =?UTF-8?q?on=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the complete set of changes to move a consumer from 5.1.x to 5.2.0, all verified against a real downstream app (USBE) that then ran locally and served its KiteUI web client unchanged: - Bump service-abstractions to the version 5.2 was built against (1.2.0-1-b2f7bd67); a mismatch compiles but throws NoSuchMethodError at runtime (SettingContext.getOpenTelemetry()). - New required AuthEndpoints/SessionManager `cache` parameter. - Telemetry moved from OpenTelemetrySettings to TelemetryBackend.Settings (both the code setting and the settings.json `"telemetry"` entry, which was previously `null`). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Uur4dHcoa1a259iiTzvin3 (cherry picked from commit 84978bcb6139c11cb59c4643f59e3adaaa68671a) (cherry picked from commit d2fd0af1267921a10969ec093f19a4116d63c17f) --- docs/migration-v5.1-to-v5.2.md | 133 +++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/migration-v5.1-to-v5.2.md 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..57efcdb83 --- /dev/null +++ b/docs/migration-v5.1-to-v5.2.md @@ -0,0 +1,133 @@ +# 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. The changes below are the complete set: one dependency bump, two +source edits, and one settings-file edit — all small and mechanical. + +!!! tip + These 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 — nothing else was required, and the app + ran and served its client unchanged afterward. + +## 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. + +## 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. From a123e5727ca6cceee453046e829d2df636fbfa5e Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 03:28:10 -0600 Subject: [PATCH 03/11] Normalize identifiers before keying the auth rate limiter PasswordProofEndpoints, TimeBasedOTPProofEndpoints, and BackupCodeEndpoints built their constrainAttemptRate cache key from the raw input value while normalization happened only inside the rate-limited action. An attacker could therefore vary case/whitespace of an identifier to get a fresh limiter bucket per variant, dodging the limiter and its new exponential backoff. Resolve the principal handler and normalize the value before constructing the key, matching PinBasedProofEndpoints. Credential-matching behavior is unchanged. Tests assert case variants of one identifier share a single bucket. Co-Authored-By: Claude Fable 5 (cherry picked from commit 7a2d926dedef59a65fac6282fc84d0f0eb5477e7) (cherry picked from commit afcde3ed6e64eb24e6caf48ef8d0d4c42341ee93) --- .../sessions/proofs/BackupCodeEndpoints.kt | 17 ++-- .../sessions/proofs/PasswordProofEndpoints.kt | 14 ++-- .../proofs/TimeBasedOTPProofEndpoints.kt | 14 ++-- .../proofs/BackupCodeEndpointsTest.kt | 69 +++++++++++++++++ .../proofs/PasswordProofEndpointsTest.kt | 63 +++++++++++++++ .../proofs/TimeBasedOTPProofEndpointsTest.kt | 77 +++++++++++++++++++ 6 files changed, 238 insertions(+), 16 deletions(-) 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..45a4471a8 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 @@ -170,14 +170,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/PasswordProofEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/PasswordProofEndpoints.kt index 5a97a91cc..107f7a826 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 @@ -165,11 +165,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..a756b4843 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 @@ -153,13 +153,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/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}" + ) + } + } + } } From c132ee86031d8ab4653e63cc37567d934a2ac7a2 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 03:19:13 -0600 Subject: [PATCH 04/11] Document error responses in generated OpenAPI Typed endpoints declare errorCases (LSError with status/detail/message), but only the success response was emitted. Emit each declared error case as a documented response grouped by HTTP status, using the LSError schema and an example, mirroring the success-response emission. Adds a test confirming error responses and path parameters appear in the spec (path parameters were already emitted at path-item level; the test locks that in). Co-Authored-By: Claude Fable 5 (cherry picked from commit 9a9c0efcb52b9506a6fc93af130af844151a188a) (cherry picked from commit a2a356354eb62575b0e53b13c4cc3eaa900dff96) --- .../typed/jsonschema/OpenApi.kt | 27 +++++- .../typed/jsonschema/OpenApiTest.kt | 83 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 typed/src/test/kotlin/com/lightningkite/lightningserver/typed/jsonschema/OpenApiTest.kt 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/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 }) + } + } +} From 9a776c7cdee84ffc490135e8f02a81b836490191 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 03:19:13 -0600 Subject: [PATCH 05/11] Declare the errors ModelRestEndpoints throws Several auto-CRUD endpoints threw BadRequestException(detail="unique") on unique-constraint violations and NotFoundException on upsert/replace without declaring them, producing W6 "undeclared error" warnings at boot. Add shared notFoundError (404) and uniqueViolationError (400, "unique") LSError constants to the errorCases of every endpoint that throws them, so the errors are documented and the warnings stop. Read-only endpoints that never throw keep empty error lists. A test asserts each throwing endpoint declares the error it can raise. Co-Authored-By: Claude Fable 5 (cherry picked from commit acc5281c1a8622b17b49768ec065c5389d0e0eb1) (cherry picked from commit 69959eaff00d570c850c250b968d1433de23393c) --- .../typed/ModelRestEndpoints.kt | 70 +++++++------------ .../typed/ModelRestEndpointsTest.kt | 31 ++++++++ 2 files changed, 55 insertions(+), 46 deletions(-) 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/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt index 4cea1d490..633fb1da5 100644 --- a/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt +++ b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt @@ -552,6 +552,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 = { From 8b29a23cf813f6512364ce9c776771c738364ab1 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 7 Jul 2026 03:19:13 -0600 Subject: [PATCH 06/11] Refresh stale TypeScript SDK snapshots The committed golden TS snapshots had drifted from the current generator. Regenerating disambiguates nested type names (Mode -> UpdateRestrictions Mode, Part -> UpdateRestrictionsPart), matching the generator's intended output. Changes are confined to the type definitions and their imports. Co-Authored-By: Claude Fable 5 (cherry picked from commit 22272fcbfd568327099101872e9bc2acbb26c4ec) (cherry picked from commit 6286e37c3dffa7854f56d6cd8a0602a9e230c37d) --- .../typed/sdk/generated/typescript/Api.ts | 2 +- .../typed/sdk/generated/typescript/LiveApi.ts | 2 +- .../typed/sdk/generated/typescript/models.ts | 26 +++++++++---------- .../typed/sdk/generated/typescript/sdk.ts | 26 +++++++++---------- 4 files changed, 28 insertions(+), 28 deletions(-) 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..9f6c0118c 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, UpdateRestrictionsMode, UpdateRestrictionsPart, 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..30f0a2715 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, UpdateRestrictionsMode, UpdateRestrictionsPart, 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..358db0280 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,11 +11,6 @@ export interface Mask { pairs: Array, Modification>> } -export enum Mode { - Blacklist = "Blacklist", - Whitelist = "Whitelist", -} - export interface ModelPermissions { create: Condition read: Condition @@ -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 @@ -48,8 +37,19 @@ export interface TestModel { } export interface UpdateRestrictions { - mode: Mode - fields: Array> + mode: UpdateRestrictionsMode + fields: Array> +} + +export enum UpdateRestrictionsMode { + Blacklist = "Blacklist", + Whitelist = "Whitelist", +} + +export interface UpdateRestrictionsPart { + property: DataClassPathPartial + requires: Condition + limitedTo: Condition } 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..2ea1d21a8 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,11 +11,6 @@ export interface Mask { pairs: Array, Modification>> } -export enum Mode { - Blacklist = "Blacklist", - Whitelist = "Whitelist", -} - export interface ModelPermissions { create: Condition read: Condition @@ -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 @@ -48,8 +37,19 @@ export interface TestModel { } export interface UpdateRestrictions { - mode: Mode - fields: Array> + mode: UpdateRestrictionsMode + fields: Array> +} + +export enum UpdateRestrictionsMode { + Blacklist = "Blacklist", + Whitelist = "Whitelist", +} + +export interface UpdateRestrictionsPart { + property: DataClassPathPartial + requires: Condition + limitedTo: Condition } export type Uuid = string // kotlin.uuid.Uuid From 998cf4d7696f432caf6ca2208c289b97c34d2f3f Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Mon, 27 Jul 2026 12:25:39 -0600 Subject: [PATCH 07/11] New table handling with pre-deploy tasks (cherry picked from commit 28d37899f04a39df433e0013b6e34aee7ceb44be) (cherry picked from commit 2b93895a46e794e9ff9623f7c35b17651fbfd08c) --- .claude/skills/lightning-server.md | 15 ++- CLAUDE.md | 7 +- .../lightningserver/auth/PrincipalType.kt | 5 +- .../lightningserver/auth/index.md | 3 +- .../lightningserver/http/HttpHandler.kt | 5 +- .../lightningserver/http/index.md | 5 +- .../lightningserver/demo/Server.kt | 19 +-- .../endpoints/DatabaseExamplesEndpoints.kt | 31 +++-- .../lightningserver/demo/main.kt | 19 ++- .../lightningserver/demo/TestModelTest.kt | 13 ++- .../aws/ec2/TerraformAwsEc2BuilderBase.kt | 73 ++++++++++++ .../aws/ec2/TerraformAwsScalingEc2Builder.kt | 39 +++++++ .../aws/ec2/TerraformAwsSingleEc2Builder.kt | 44 +++++++ docs-guide/drafts/advanced-database.md | 10 +- docs-guide/drafts/media.md | 2 +- docs-guide/drafts/oauth.md | 6 +- docs-guide/drafts/permissions.md | 4 +- docs-guide/drafts/pubsub.md | 4 +- docs-guide/drafts/vs-django.md | 4 +- docs-guide/drafts/vs-rails.md | 8 +- docs-guide/drafts/vs-spring.md | 14 +-- docs-guide/guide/auth.md | 11 +- docs-guide/guide/caching.md | 4 +- docs-guide/guide/database.md | 16 ++- docs-guide/guide/validation.md | 2 +- .../guide/samples/AuthSamples.kt | 9 +- .../guide/samples/DatabaseSamples.kt | 12 +- docs/authentication.md | 12 +- docs/compared-to-django.md | 18 +-- docs/compared-to-rails.md | 2 +- docs/compared-to-spring.md | 16 +-- docs/database.md | 15 ++- docs/endpoints.md | 6 +- docs/media.md | 6 +- docs/migration-v4-to-v5.md | 2 +- docs/migration-v5.1-to-v5.2.md | 110 +++++++++++++++++- docs/sessions.md | 2 +- .../engine/awsserverless/AwsAdapter.kt | 10 ++ .../TerraformAwsServerlessBuilder.kt | 23 ++++ .../engine/local/LocalEngine.kt | 9 ++ .../files/UploadEarlyEndpoints.kt | 17 ++- .../lightningserver/media/index.md | 3 +- plans/all-api-suggestions.md | 2 +- .../lightningserver/typed/ModelInfo.kt | 26 ++++- .../lightningserver/typed/doOnce.kt | 39 ++++++- .../lightningserver/typed/DoOnceTest.kt | 12 +- .../typed/sdk/generated/typescript/Api.ts | 2 +- .../typed/sdk/generated/typescript/LiveApi.ts | 2 +- .../typed/sdk/generated/typescript/models.ts | 17 +-- .../typed/sdk/generated/typescript/sdk.ts | 17 +-- 50 files changed, 585 insertions(+), 167 deletions(-) diff --git a/.claude/skills/lightning-server.md b/.claude/skills/lightning-server.md index 2b46ef452..16c9af396 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 +`DatabaseTableDefinition` once per model and share it (prepare it once per deploy in a `PreDeployTask` +with `database().prepare(postTable)`): ```kotlin -val posts = database().table() +val postTable = DatabaseTableDefinition() // define once, share across your app +val posts = database().table(postTable) // 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 = DatabaseTableDefinition() // define once, share across your app + // Scheduled task val cleanup = path.path("scheduled-cleanup") bind ScheduledTask( frequency = 1.hours ) { println("Running cleanup...") - database().table().deleteMany(condition { + database().table(oldDataTable).deleteMany(condition { it.createdAt lt Clock.System.now() - 30.days }) } @@ -351,11 +356,13 @@ val value = cache().get("key") cache().remove("key") // Cache-aside pattern +val dataTable = DatabaseTableDefinition() // define once, share across your app + 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 = database().table(dataTable).get(id) cache().set("data:$id", fresh, expire = 10.minutes) return fresh } diff --git a/CLAUDE.md b/CLAUDE.md index 2685e474f..dc4a21e8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,10 +236,13 @@ data class Post( ) : HasId ``` -Database operations use a DSL for type-safe queries: +Database operations use a DSL for type-safe queries. Define a `DatabaseTableDefinition` once per model +and share it (prepare it once per deploy with `database().prepare(postTable)`, typically in a +`PreDeployTask`): ```kotlin -val posts = database().table() +val postTable = DatabaseTableDefinition() // define once, share across your app +val posts = database().table(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..4093f7c9b 100644 --- a/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md +++ b/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md @@ -117,10 +117,11 @@ data class User( 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") } 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..e51ea6a53 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,12 @@ includes types for requests, responses, headers, status codes, URL parsing, and ### Basic Request Handler ```kotlin +// Define the table once and share it; it is the key backends use to locate the table. +val userTable = DatabaseTableDefinition() + val endpoint = 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/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt index 38d5e6b0a..0c5ff2cde 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 { 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..a19145da9 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.* @@ -54,6 +55,16 @@ 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 = DatabaseTableDefinition() + private val commentTable = DatabaseTableDefinition() + + val prepareTables = path.path("prepare") bind PreDeployTask { + database().prepare(postTable) + database().prepare(commentTable) + } + /** * POST /blog/posts * @@ -85,7 +96,7 @@ class DatabaseExamplesEndpoints( status = PostStatus.DRAFT ) - database().table().insertOne(post) + database().table(postTable).insertOne(post) } ) @@ -103,7 +114,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 = database().table(postTable) // Simple condition for published posts val condition: Condition = condition { it.status eq PostStatus.PUBLISHED } @@ -141,7 +152,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.OK, implementation = { _: Unit -> val id = route.arg1 - val posts = database().table() + val posts = database().table(postTable) val post = posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -171,7 +182,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.OK, implementation = { input: UpdatePostRequest -> val id = route.arg1 - val posts = database().table() + val posts = database().table(postTable) // Check if post exists posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -220,8 +231,8 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.NoContent, implementation = { _: Unit -> val id = route.arg1 - val posts = database().table() - val comments = database().table() + val posts = database().table(postTable) + val comments = database().table(commentTable) // Check if post exists posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -251,7 +262,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.Created, implementation = { input: CreateCommentRequest -> val postId = route.arg1 - val posts = database().table() + val posts = database().table(postTable) // Verify post exists posts.get(postId) ?: throw NotFoundException("Blog post not found") @@ -267,7 +278,7 @@ class DatabaseExamplesEndpoints( parentCommentId = input.parentCommentId ) - database().table().insertOne(comment) + database().table(commentTable).insertOne(comment) } ) @@ -285,7 +296,7 @@ class DatabaseExamplesEndpoints( implementation = { _: Unit -> val postId = route.arg1 - database().table() + database().table(commentTable) .find( condition = condition { (it.postId eq postId) and (it.isApproved eq true) @@ -308,7 +319,7 @@ class DatabaseExamplesEndpoints( auth = noAuth, successCode = HttpStatus.OK, implementation = { input: SearchPostsRequest -> - val posts = database().table() + val posts = database().table(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..10a9a0829 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 = database().table(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 = database().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 = database().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() + 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 = database().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..777cebbfd 100644 --- a/docs-guide/drafts/media.md +++ b/docs-guide/drafts/media.md @@ -129,7 +129,7 @@ object Server : ServerBuilder() { val database = setting("database", Database.Settings()) val products = database() - .table() + .table(productTable) .interceptImagesForProcessingNotNull( MediaPreviewOptions(sizeInPixels = 200), // thumbnail MediaPreviewOptions(sizeInPixels = 1200), // full-size web diff --git a/docs-guide/drafts/oauth.md b/docs-guide/drafts/oauth.md index 2f48724f1..bcee5f21f 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.database().table(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.database().table(userTable).findOne(condition { it.email eq value }) + ?: Server.database().table(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..b5ae4299c 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.database().table(userTable).get(id) ?: throw NotFoundException() } ``` @@ -406,7 +406,7 @@ val publishedFeed = path.path("feed").get bind ApiHttpHandler( ) ``` -Do not call `database().table()` directly in secured endpoints — that +Do not call `database().table(postTable)` directly in secured endpoints — that bypasses all permission enforcement. ## Blog post example (from demo) diff --git a/docs-guide/drafts/pubsub.md b/docs-guide/drafts/pubsub.md index 3d0246ded..1c42b904a 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) + database().table(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 = database().table(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..493be690a 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 = database().table(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() + database().table(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..bf3262edd 100644 --- a/docs-guide/drafts/vs-rails.md +++ b/docs-guide/drafts/vs-rails.md @@ -123,7 +123,7 @@ end ```kotlin // Lightning Server — illustrative -val posts = database().table() +val posts = database().table(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 = database().table(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() + database().table(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.database().table(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..d60e5c7d3 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)) + database().table(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()) + database().table(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 = database().table(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 = database().table(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 = database().table(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 = database().table(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) + database().table(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..7d62d6932 100644 --- a/docs-guide/guide/auth.md +++ b/docs-guide/guide/auth.md @@ -24,6 +24,7 @@ All examples in this chapter use the following imports: ```kotlin import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.* @@ -89,7 +90,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): UserProfile = - UserProfileServer.database().table().get(id) + UserProfileServer.database().table(UserProfileServer.userProfiles).get(id) ?: throw NotFoundException("User not found") } ``` @@ -120,6 +121,10 @@ declare that the endpoint requires a `UserProfile` token. Compare this to object UserProfileServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // Prepare the table once per deploy (before serving); access it with database().table(userProfiles). + val userProfiles = DatabaseTableDefinition() + val prepareUserProfiles = path.path("prepare") bind PreDeployTask { database().prepare(userProfiles) } + init { // register() makes this principal type discoverable when deserializing tokens. register(UserAuth) @@ -176,7 +181,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.database().table(UserProfileServer.userProfiles) .insertOne(UserProfile(name = "Alice", email = "alice@example.com")) // testAuth() creates an Authentication for use in tests. @@ -243,7 +248,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.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..6ba476cfd 100644 --- a/docs-guide/guide/caching.md +++ b/docs-guide/guide/caching.md @@ -84,7 +84,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 = database().table(userTable).get(id) ?: throw NotFoundException("user $id not found") // 3. Populate cache for future reads @@ -98,7 +98,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 = database().table(userTable).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..2a672202f 100644 --- a/docs-guide/guide/database.md +++ b/docs-guide/guide/database.md @@ -29,6 +29,7 @@ All examples in this chapter use the following imports: ```kotlin import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.test.* @@ -79,6 +80,11 @@ database suitable for tests and local development: object NoteDbServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // A table definition names the table and its type. Prepare it once per deploy (before serving), + // then access it at runtime with database().table(notes). + val notes = DatabaseTableDefinition() + val prepareNotes = path.path("prepare") bind PreDeployTask { database().prepare(notes) } + // GET /notes — list all notes val list = path.path("notes").get bind ApiHttpHandler( summary = "List all notes", @@ -86,7 +92,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.OK, errorCases = emptyList(), implementation = { _: Unit -> - database().table().find(Condition.Always).toList() + database().table(notes).find(Condition.Always).toList() } ) @@ -97,7 +103,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.Created, errorCases = emptyList(), implementation = { input: Note -> - database().table().insertOne(input) + database().table(notes).insertOne(input) } ) } @@ -105,8 +111,8 @@ object NoteDbServer : ServerBuilder() { Key points: -- **`database().table()`** — `database()` resolves the live service from - the current `ServerRuntime` (only callable inside a handler). `.table()` +- **`database().table(notes)`** — `database()` resolves the live service from + the current `ServerRuntime` (only callable inside a handler). `.table(notes)` returns a `Table` keyed on the serializer, so one table per model type. - **`Condition.Always`** — matches every document. Use `condition { }` to narrow the query (shown in the test below). @@ -139,7 +145,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.database().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/validation.md b/docs-guide/guide/validation.md index 79e5134a6..dc97f30c6 100644 --- a/docs-guide/guide/validation.md +++ b/docs-guide/guide/validation.md @@ -261,7 +261,7 @@ For asynchronous checks (for example, checking a database for uniqueness): // Illustrative. AnnotationValidators { validateSuspending { value -> - if (database().table().count(condition { it.email eq value }) > 0) + if (database().table(userTable).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..68f2a646f 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 @@ -3,6 +3,7 @@ package com.lightningkite.lightningserver.guide.samples // region auth-imports import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.* @@ -37,7 +38,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): UserProfile = - UserProfileServer.database().table().get(id) + UserProfileServer.database().table(UserProfileServer.userProfiles).get(id) ?: throw NotFoundException("User not found") } // endregion user-auth @@ -46,6 +47,10 @@ object UserAuth : PrincipalType { object UserProfileServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // Prepare the table once per deploy (before serving); access it with database().table(userProfiles). + val userProfiles = DatabaseTableDefinition() + val prepareUserProfiles = path.path("prepare") bind PreDeployTask { database().prepare(userProfiles) } + init { // register() makes this principal type discoverable when deserializing tokens. register(UserAuth) @@ -74,7 +79,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.database().table(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..325e8fbf6 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 @@ -3,6 +3,7 @@ package com.lightningkite.lightningserver.guide.samples // region db-imports import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* +import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.test.* @@ -30,6 +31,11 @@ data class Note( object NoteDbServer : ServerBuilder() { val database = setting("database", Database.Settings()) + // A table definition names the table and its type. Prepare it once per deploy (before serving), + // then access it at runtime with database().table(notes). + val notes = DatabaseTableDefinition() + val prepareNotes = path.path("prepare") bind PreDeployTask { database().prepare(notes) } + // GET /notes — list all notes val list = path.path("notes").get bind ApiHttpHandler( summary = "List all notes", @@ -37,7 +43,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.OK, errorCases = emptyList(), implementation = { _: Unit -> - database().table().find(Condition.Always).toList() + database().table(notes).find(Condition.Always).toList() } ) @@ -48,7 +54,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.Created, errorCases = emptyList(), implementation = { input: Note -> - database().table().insertOne(input) + database().table(notes).insertOne(input) } ) } @@ -65,7 +71,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.database().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/authentication.md b/docs/authentication.md index 697b21ac7..4dba8685b 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -41,15 +41,15 @@ object Server : ServerBuilder() { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + database().table(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 = database().table(userTable) .findOne(condition { it.email eq value }) - existing ?: database().table() + existing ?: database().table(userTable) .insertOne(User(email = value)) } else -> super.fetchByProperty(property, value) @@ -104,15 +104,15 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + database().table(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)) + database().table(userTable).findOne(condition { it.email eq value }) + ?: database().table(userTable).insertOne(User(email = value)) } else -> super.fetchByProperty(property, value) } diff --git a/docs/compared-to-django.md b/docs/compared-to-django.md index 345b014f6..ce7e312e9 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 = database().table(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 = database().table(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(database().table(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 = database().table(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() + database().table(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(database().table(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 = database().table(postTable).insertOne(data) HttpResponse( body = TypedData.json(mapOf("id" to created._id)), status = HttpStatus.Created @@ -467,7 +467,7 @@ val createPost = path.path("posts").post bind ApiHttpHandler<_, User?, CreatePos author = user.email, body = input.body ) - database().table().insertOne(post) + database().table(postTable).insertOne(post) post } ) @@ -562,7 +562,7 @@ object Server : ServerBuilder() { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): User = - database().table().get(id) ?: throw NotFoundException() + database().table(userTable).get(id) ?: throw NotFoundException() } // Protected endpoint diff --git a/docs/compared-to-rails.md b/docs/compared-to-rails.md index 4039df9f2..ac390d351 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 = database().table(postTable) .interceptCreate { value -> // Modify before insertion (like before_create) value.copy(slug = value.title.slugify()) diff --git a/docs/compared-to-spring.md b/docs/compared-to-spring.md index 9a24a3553..517b5cc57 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` | `database().table(tTable)` | 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)) + database().table(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()) + database().table(userTable).insertOne(request.toUser()) } ) } @@ -161,7 +161,7 @@ data class User( ) : HasId // Usage - no interface to define! -val users = database().table() +val users = database().table(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() + database().table(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 = database().table(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 = database().table(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.database().table(userTable).insertOne( User(_id = Uuid.parse("..."), email = "test@example.com") ) diff --git a/docs/database.md b/docs/database.md index 51cce51ec..9b6be87b6 100644 --- a/docs/database.md +++ b/docs/database.md @@ -46,11 +46,20 @@ data class Post( ## Accessing the database -You can now access a table of these objects like this: +A table is identified by a `DatabaseTableDefinition`. **Create one per model and share it across your +application** — it is the key backends use to locate the table, so you should not construct a new one at +each call site: + +```kotlin +val postTable = DatabaseTableDefinition() +``` + +Prepare it once per deploy (before serving) so the collection/indexes exist — typically as a +[pre-deploy task](tasks.md); `database().prepare(postTable)`. Then access it inside a handler: ```kotlin val db = database() -val posts = db.table() +val posts = db.table(postTable) // Insert a new post posts.insertOne(Post( @@ -150,7 +159,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 = database().table(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..10cac6f48 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 = database().table(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 = database().table(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 = database().table(userTable).get(request.path.arg1) HttpResponse.json(user) } diff --git a/docs/media.md b/docs/media.md index d9631b9df..9489314ea 100644 --- a/docs/media.md +++ b/docs/media.md @@ -96,7 +96,7 @@ object Server : ServerBuilder() { val database = setting("database", Database.Settings()) val posts = database - .table() + .table(postTable) .interceptImagesForProcessing( MediaPreviewOptions(sizeInPixels = 200), MediaPreviewOptions(sizeInPixels = 800), @@ -181,7 +181,7 @@ object Server : ServerBuilder() { // Use interceptor for immediate availability val products = database - .table() + .table(productTable) .interceptImagesForProcessing( MediaPreviewOptions(sizeInPixels = 100), // Thumbnail MediaPreviewOptions(sizeInPixels = 400), // Medium @@ -248,7 +248,7 @@ 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() + .table(documentTable) .interceptImagesForProcessing( MediaPreviewOptions(sizeInPixels = 200), makePath = { it.path { it.attachment } } diff --git a/docs/migration-v4-to-v5.md b/docs/migration-v4-to-v5.md index ed78c9811..5afa81203 100644 --- a/docs/migration-v4-to-v5.md +++ b/docs/migration-v4-to-v5.md @@ -115,7 +115,7 @@ 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 = database().table().count() +suspend fun countUsers(): Int = database().table(userTable).count() ``` This is the most pervasive source of migration compile errors: functions called from handlers, hooks, or signals diff --git a/docs/migration-v5.1-to-v5.2.md b/docs/migration-v5.1-to-v5.2.md index 57efcdb83..16c1a7421 100644 --- a/docs/migration-v5.1-to-v5.2.md +++ b/docs/migration-v5.1-to-v5.2.md @@ -4,13 +4,14 @@ 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. The changes below are the complete set: one dependency bump, two -source edits, and one settings-file edit — all small and mechanical. +[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 - These 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 — nothing else was required, and the app - ran and served its client unchanged afterward. + 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 @@ -124,6 +125,105 @@ object LkEnv : TerraformAwsServerlessDomainBuilder(Server) { 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` diff --git a/docs/sessions.md b/docs/sessions.md index 84c522679..6fdd5599f 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -128,7 +128,7 @@ object Server : ServerBuilder() { ) val passwordProof = PasswordProofEndpoints( - table = database.table(), + table = database.table(passwordSecretTable), getSubjectId = { it.email }, hash = { it.secureHash() } ) 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..b81b7fed6 100644 --- a/files/src/main/kotlin/com/lightningkite/lightningserver/files/UploadEarlyEndpoints.kt +++ b/files/src/main/kotlin/com/lightningkite/lightningserver/files/UploadEarlyEndpoints.kt @@ -46,6 +46,13 @@ public class UploadEarlyEndpoint( sdkSettings.clientInterface = ClientUploadEarlyEndpoints::class.info() } + private val uploadForNextRequestTable = DatabaseTableDefinition() + + /** Prepares the backing table (collection/indexes) once per deploy, before serving. */ + public val prepareTable: PreDeployTask = path.path("prepare") bind PreDeployTask { + database().prepare(uploadForNextRequestTable) + } + /** * 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 +66,7 @@ public class UploadEarlyEndpoint( fileSystems = listOf(files()), onUse = { fileObject -> runBlocking { - database().table() + database().table(uploadForNextRequestTable) .deleteManyIgnoringOld(condition { it.file eq ServerFile(fileObject.url) }) } }, @@ -95,7 +102,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(newFile.url) ) - database().table().insertOne(newItem) + database().table(uploadForNextRequestTable).insertOne(newItem) UploadInformation( uploadUrl = newFile.uploadUrl(expiration), futureCallToken = serializer().certifyAlreadyScannedForUse(key, expiration) @@ -106,7 +113,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(newFile.url) ) - database().table().insertOne(newItem) + database().table(uploadForNextRequestTable).insertOne(newItem) UploadInformation( uploadUrl = newFile.uploadUrl(expiration), futureCallToken = serializer().certifyForUse(key, expiration) @@ -136,7 +143,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(safe.url) ) - database().table().insertOne(newItem) + database().table(uploadForNextRequestTable).insertOne(newItem) url } @@ -146,7 +153,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 { + database().table(uploadForNextRequestTable).deleteMany(condition { it.expires lt now() }).forEach { try { files().parseInternalUrl(it.file.location)!!.delete() } catch (e: Exception) { 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..80be0fae7 100644 --- a/media/src/main/kotlin/com/lightningkite/lightningserver/media/index.md +++ b/media/src/main/kotlin/com/lightningkite/lightningserver/media/index.md @@ -43,9 +43,10 @@ that meet or exceed requirements. context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) + val postTable = DatabaseTableDefinition() // define once, share across your app val posts = database - .table() + .table(postTable) .interceptImagesForProcessing( MediaPreviewOptions(sizeInPixels = 200), MediaPreviewOptions(sizeInPixels = 800), 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/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt index 4460ba901..53c4bcc96 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,9 @@ public interface ModelInfo?, T : HasId, ID : Comparable + public val tableDefinition: DatabaseTableDefinition + public val preDeployTask: PreDeployTask + public object Scopes { public val create: Subscope = Subscope("create") public val read: Subscope = Subscope("read") @@ -60,9 +63,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 { + override val tableDefinition: DatabaseTableDefinition = DatabaseTableDefinition(serializer, tableName) + // Table/index reconciliation runs as a pre-deploy task: once per deploy, before the new version + // serves, rather than on every instance's boot (keeps it off the cold-start path). It is + // convergent (reconciles the DB to the current model), so running it every deploy is correct. + override val preDeployTask: PreDeployTask = with(builder) { + path.path(tableName) bind PreDeployTask { this@modelInfo().prepare(tableDefinition) } } @@ -114,8 +120,18 @@ public fun ?, T : HasId, ID : Comparable> Runtime = subscope?.let { auth.subscope(it) } ?: auth + override val tableDefinition: DatabaseTableDefinition = DatabaseTableDefinition(serializer, tableName) + // Table/index reconciliation runs as a pre-deploy task: once per deploy, before the new version + // serves, rather than on every instance's boot (keeps it off the cold-start path). It is + // convergent (reconciles the DB to the current model), so running it every deploy is correct. + override val preDeployTask: PreDeployTask = with(builder) { + path.path(tableName) bind PreDeployTask { + this@explicitModelInfo().prepare(tableDefinition) + } + } + context(server: ServerRuntime) - override fun baseTable(): Table = this@explicitModelInfo().table(serializer, tableName) + override fun baseTable(): Table = this@explicitModelInfo().table(tableDefinition) override val tableName: String get() = tableName 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/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/sdk/generated/typescript/Api.ts b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/sdk/generated/typescript/Api.ts index 9f6c0118c..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, ModelPermissions, Pair, TestInput, TestModel, UpdateRestrictions, UpdateRestrictionsMode, UpdateRestrictionsPart, 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 30f0a2715..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, ModelPermissions, Pair, TestInput, TestModel, UpdateRestrictions, UpdateRestrictionsMode, UpdateRestrictionsPart, 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 358db0280..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 @@ -16,7 +16,7 @@ export interface ModelPermissions { read: Condition readMask: Mask update: Condition - updateRestrictions: UpdateRestrictions + updateRestrictions: UpdateRestrictions delete: Condition maxQueryTimeMs: number } @@ -36,20 +36,7 @@ export interface TestModel { name: string } -export interface UpdateRestrictions { - mode: UpdateRestrictionsMode - fields: Array> -} - -export enum UpdateRestrictionsMode { - Blacklist = "Blacklist", - Whitelist = "Whitelist", -} - -export interface UpdateRestrictionsPart { - property: DataClassPathPartial - requires: Condition - limitedTo: Condition +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 2ea1d21a8..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 @@ -16,7 +16,7 @@ export interface ModelPermissions { read: Condition readMask: Mask update: Condition - updateRestrictions: UpdateRestrictions + updateRestrictions: UpdateRestrictions delete: Condition maxQueryTimeMs: number } @@ -36,20 +36,7 @@ export interface TestModel { name: string } -export interface UpdateRestrictions { - mode: UpdateRestrictionsMode - fields: Array> -} - -export enum UpdateRestrictionsMode { - Blacklist = "Blacklist", - Whitelist = "Whitelist", -} - -export interface UpdateRestrictionsPart { - property: DataClassPathPartial - requires: Condition - limitedTo: Condition +export interface UpdateRestrictions { } export type Uuid = string // kotlin.uuid.Uuid From c52dcaaee6b39d8e7a1b559cfe04596fe0425165 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Mon, 27 Jul 2026 15:24:59 -0600 Subject: [PATCH 08/11] Table registration (cherry picked from commit 46924217f4f60f9c86b9ba7fc963745a670081eb) (cherry picked from commit c4f207405d80857a67f3031be94384a7bec8ae75) --- .claude/skills/lightning-server.md | 17 ++-- CLAUDE.md | 13 ++- .../lightningserver/definition/Extensions.kt | 12 ++- .../definition/ExtensionsTest.kt | 18 ++++ .../endpoints/DatabaseExamplesEndpoints.kt | 30 +++--- docs-guide/guide/auth.md | 13 ++- docs-guide/guide/database.md | 21 ++-- .../guide/samples/AuthSamples.kt | 11 +-- .../guide/samples/DatabaseSamples.kt | 14 ++- docs/database.md | 20 ++-- .../files/UploadEarlyEndpoints.kt | 19 ++-- .../typed/DatabaseTableRegistration.kt | 97 +++++++++++++++++++ .../lightningserver/typed/ModelInfo.kt | 31 ++---- .../typed/DatabaseTableRegistrationTest.kt | 61 ++++++++++++ 14 files changed, 271 insertions(+), 106 deletions(-) create mode 100644 typed/src/main/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistration.kt create mode 100644 typed/src/test/kotlin/com/lightningkite/lightningserver/typed/DatabaseTableRegistrationTest.kt diff --git a/.claude/skills/lightning-server.md b/.claude/skills/lightning-server.md index 16c9af396..c6d2a5c9a 100644 --- a/.claude/skills/lightning-server.md +++ b/.claude/skills/lightning-server.md @@ -162,12 +162,12 @@ 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. Define a -`DatabaseTableDefinition` once per model and share it (prepare it once per deploy in a `PreDeployTask` -with `database().prepare(postTable)`): +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 postTable = DatabaseTableDefinition() // define once, share across your app -val posts = database().table(postTable) +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")) @@ -328,14 +328,14 @@ val sendEmail = path.path("send-email").post bind HttpHandler { request -> HttpResponse.plainText("Email queued") } -val oldDataTable = DatabaseTableDefinition() // define once, share across your app +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(oldDataTable).deleteMany(condition { + oldDataTable().deleteMany(condition { it.createdAt lt Clock.System.now() - 30.days }) } @@ -356,13 +356,12 @@ val value = cache().get("key") cache().remove("key") // Cache-aside pattern -val dataTable = DatabaseTableDefinition() // define once, share across your app - +// 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(dataTable).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 dc4a21e8c..451b4897b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,13 +236,16 @@ data class Post( ) : HasId ``` -Database operations use a DSL for type-safe queries. Define a `DatabaseTableDefinition` once per model -and share it (prepare it once per deploy with `database().prepare(postTable)`, typically in a -`PreDeployTask`): +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 postTable = DatabaseTableDefinition() // define once, share across your app -val posts = database().table(postTable) +// 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/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/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/endpoints/DatabaseExamplesEndpoints.kt b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/endpoints/DatabaseExamplesEndpoints.kt index a19145da9..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 @@ -10,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 @@ -57,13 +58,8 @@ class DatabaseExamplesEndpoints( // 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 = DatabaseTableDefinition() - private val commentTable = DatabaseTableDefinition() - - val prepareTables = path.path("prepare") bind PreDeployTask { - database().prepare(postTable) - database().prepare(commentTable) - } + private val postTable = database.registerTable("BlogPost") + private val commentTable = database.registerTable("Comment") /** * POST /blog/posts @@ -96,7 +92,7 @@ class DatabaseExamplesEndpoints( status = PostStatus.DRAFT ) - database().table(postTable).insertOne(post) + postTable().insertOne(post) } ) @@ -114,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(postTable) + val posts = postTable() // Simple condition for published posts val condition: Condition = condition { it.status eq PostStatus.PUBLISHED } @@ -152,7 +148,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.OK, implementation = { _: Unit -> val id = route.arg1 - val posts = database().table(postTable) + val posts = postTable() val post = posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -182,7 +178,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.OK, implementation = { input: UpdatePostRequest -> val id = route.arg1 - val posts = database().table(postTable) + val posts = postTable() // Check if post exists posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -231,8 +227,8 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.NoContent, implementation = { _: Unit -> val id = route.arg1 - val posts = database().table(postTable) - val comments = database().table(commentTable) + val posts = postTable() + val comments = commentTable() // Check if post exists posts.get(id) ?: throw NotFoundException("Blog post not found") @@ -262,7 +258,7 @@ class DatabaseExamplesEndpoints( successCode = HttpStatus.Created, implementation = { input: CreateCommentRequest -> val postId = route.arg1 - val posts = database().table(postTable) + val posts = postTable() // Verify post exists posts.get(postId) ?: throw NotFoundException("Blog post not found") @@ -278,7 +274,7 @@ class DatabaseExamplesEndpoints( parentCommentId = input.parentCommentId ) - database().table(commentTable).insertOne(comment) + commentTable().insertOne(comment) } ) @@ -296,7 +292,7 @@ class DatabaseExamplesEndpoints( implementation = { _: Unit -> val postId = route.arg1 - database().table(commentTable) + commentTable() .find( condition = condition { (it.postId eq postId) and (it.isApproved eq true) @@ -319,7 +315,7 @@ class DatabaseExamplesEndpoints( auth = noAuth, successCode = HttpStatus.OK, implementation = { input: SearchPostsRequest -> - val posts = database().table(postTable) + val posts = postTable() // Start with published posts only var condition: Condition = condition { it.status eq PostStatus.PUBLISHED } diff --git a/docs-guide/guide/auth.md b/docs-guide/guide/auth.md index 7d62d6932..90e4eaede 100644 --- a/docs-guide/guide/auth.md +++ b/docs-guide/guide/auth.md @@ -24,7 +24,6 @@ All examples in this chapter use the following imports: ```kotlin import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* -import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.* @@ -90,7 +89,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): UserProfile = - UserProfileServer.database().table(UserProfileServer.userProfiles).get(id) + UserProfileServer.userProfiles().get(id) ?: throw NotFoundException("User not found") } ``` @@ -121,9 +120,9 @@ declare that the endpoint requires a `UserProfile` token. Compare this to object UserProfileServer : ServerBuilder() { val database = setting("database", Database.Settings()) - // Prepare the table once per deploy (before serving); access it with database().table(userProfiles). - val userProfiles = DatabaseTableDefinition() - val prepareUserProfiles = path.path("prepare") bind PreDeployTask { database().prepare(userProfiles) } + // 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. @@ -181,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(UserProfileServer.userProfiles) + val alice = UserProfileServer.userProfiles() .insertOne(UserProfile(name = "Alice", email = "alice@example.com")) // testAuth() creates an Authentication for use in tests. @@ -248,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) + UserProfileServer.userProfiles() .count(condition { it._id eq auth.id } and condition { it.role eq "admin" }) > 0 } ``` diff --git a/docs-guide/guide/database.md b/docs-guide/guide/database.md index 2a672202f..289e3e07f 100644 --- a/docs-guide/guide/database.md +++ b/docs-guide/guide/database.md @@ -29,7 +29,6 @@ All examples in this chapter use the following imports: ```kotlin import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* -import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.test.* @@ -80,10 +79,9 @@ database suitable for tests and local development: object NoteDbServer : ServerBuilder() { val database = setting("database", Database.Settings()) - // A table definition names the table and its type. Prepare it once per deploy (before serving), - // then access it at runtime with database().table(notes). - val notes = DatabaseTableDefinition() - val prepareNotes = path.path("prepare") bind PreDeployTask { database().prepare(notes) } + // 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( @@ -92,7 +90,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.OK, errorCases = emptyList(), implementation = { _: Unit -> - database().table(notes).find(Condition.Always).toList() + notes().find(Condition.Always).toList() } ) @@ -103,7 +101,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.Created, errorCases = emptyList(), implementation = { input: Note -> - database().table(notes).insertOne(input) + notes().insertOne(input) } ) } @@ -111,9 +109,10 @@ object NoteDbServer : ServerBuilder() { Key points: -- **`database().table(notes)`** — `database()` resolves the live service from - the current `ServerRuntime` (only callable inside a handler). `.table(notes)` - 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 @@ -145,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(NoteDbServer.notes) + 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/AuthSamples.kt b/docs-guide/src/samples/kotlin/com/lightningkite/lightningserver/guide/samples/AuthSamples.kt index 68f2a646f..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 @@ -3,7 +3,6 @@ package com.lightningkite.lightningserver.guide.samples // region auth-imports import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* -import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.* @@ -38,7 +37,7 @@ object UserAuth : PrincipalType { context(server: ServerRuntime) override suspend fun fetch(id: Uuid): UserProfile = - UserProfileServer.database().table(UserProfileServer.userProfiles).get(id) + UserProfileServer.userProfiles().get(id) ?: throw NotFoundException("User not found") } // endregion user-auth @@ -47,9 +46,9 @@ object UserAuth : PrincipalType { object UserProfileServer : ServerBuilder() { val database = setting("database", Database.Settings()) - // Prepare the table once per deploy (before serving); access it with database().table(userProfiles). - val userProfiles = DatabaseTableDefinition() - val prepareUserProfiles = path.path("prepare") bind PreDeployTask { database().prepare(userProfiles) } + // 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. @@ -79,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(UserProfileServer.userProfiles) + 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 325e8fbf6..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 @@ -3,7 +3,6 @@ package com.lightningkite.lightningserver.guide.samples // region db-imports import com.lightningkite.lightningserver.* import com.lightningkite.lightningserver.auth.* -import com.lightningkite.lightningserver.definition.PreDeployTask import com.lightningkite.lightningserver.definition.builder.* import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.runtime.test.* @@ -31,10 +30,9 @@ data class Note( object NoteDbServer : ServerBuilder() { val database = setting("database", Database.Settings()) - // A table definition names the table and its type. Prepare it once per deploy (before serving), - // then access it at runtime with database().table(notes). - val notes = DatabaseTableDefinition() - val prepareNotes = path.path("prepare") bind PreDeployTask { database().prepare(notes) } + // 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( @@ -43,7 +41,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.OK, errorCases = emptyList(), implementation = { _: Unit -> - database().table(notes).find(Condition.Always).toList() + notes().find(Condition.Always).toList() } ) @@ -54,7 +52,7 @@ object NoteDbServer : ServerBuilder() { successCode = HttpStatus.Created, errorCases = emptyList(), implementation = { input: Note -> - database().table(notes).insertOne(input) + notes().insertOne(input) } ) } @@ -71,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(NoteDbServer.notes) + 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/database.md b/docs/database.md index 9b6be87b6..dd7bfa896 100644 --- a/docs/database.md +++ b/docs/database.md @@ -46,20 +46,22 @@ data class Post( ## Accessing the database -A table is identified by a `DatabaseTableDefinition`. **Create one per model and share it across your -application** — it is the key backends use to locate the table, so you should not construct a new one at -each call site: +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 postTable = DatabaseTableDefinition() +object Server : ServerBuilder() { + val database = setting("database", Database.Settings()) + val postTable = database.registerTable("Post") // define + register + prepare, once +} ``` -Prepare it once per deploy (before serving) so the collection/indexes exist — typically as a -[pre-deploy task](tasks.md); `database().prepare(postTable)`. Then access it inside a handler: +The value it returns is a runtime accessor — **invoke it inside a handler** to get the live `Table`: ```kotlin -val db = database() -val posts = db.table(postTable) +val posts = postTable() // Insert a new post posts.insertOne(Post( @@ -159,7 +161,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(postTable) +val collection = postTable() .interceptCreate { value -> println("About to insert: $value") value.copy(title = value.title + " (New)") 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 b81b7fed6..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,12 +47,8 @@ public class UploadEarlyEndpoint( sdkSettings.clientInterface = ClientUploadEarlyEndpoints::class.info() } - private val uploadForNextRequestTable = DatabaseTableDefinition() - - /** Prepares the backing table (collection/indexes) once per deploy, before serving. */ - public val prepareTable: PreDeployTask = path.path("prepare") bind PreDeployTask { - database().prepare(uploadForNextRequestTable) - } + // 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 @@ -66,7 +63,7 @@ public class UploadEarlyEndpoint( fileSystems = listOf(files()), onUse = { fileObject -> runBlocking { - database().table(uploadForNextRequestTable) + uploadForNextRequestTable() .deleteManyIgnoringOld(condition { it.file eq ServerFile(fileObject.url) }) } }, @@ -102,7 +99,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(newFile.url) ) - database().table(uploadForNextRequestTable).insertOne(newItem) + uploadForNextRequestTable().insertOne(newItem) UploadInformation( uploadUrl = newFile.uploadUrl(expiration), futureCallToken = serializer().certifyAlreadyScannedForUse(key, expiration) @@ -113,7 +110,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(newFile.url) ) - database().table(uploadForNextRequestTable).insertOne(newItem) + uploadForNextRequestTable().insertOne(newItem) UploadInformation( uploadUrl = newFile.uploadUrl(expiration), futureCallToken = serializer().certifyForUse(key, expiration) @@ -143,7 +140,7 @@ public class UploadEarlyEndpoint( expires = now().plus(expiration), file = ServerFile(safe.url) ) - database().table(uploadForNextRequestTable).insertOne(newItem) + uploadForNextRequestTable().insertOne(newItem) url } @@ -153,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(uploadForNextRequestTable).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/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/ModelInfo.kt b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt index 53c4bcc96..88049a3ad 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt @@ -14,8 +14,7 @@ public interface ModelInfo?, T : HasId, ID : Comparable - public val tableDefinition: DatabaseTableDefinition - public val preDeployTask: PreDeployTask + public val registration: DatabaseTableRegistration public object Scopes { public val create: Subscope = Subscope("create") @@ -63,18 +62,12 @@ public inline fun ?, reified T : HasId, reified ID : override val auth: AuthRequirement = subscope?.let { auth.subscope(it) } ?: auth - override val tableDefinition: DatabaseTableDefinition = DatabaseTableDefinition(serializer, tableName) - // Table/index reconciliation runs as a pre-deploy task: once per deploy, before the new version - // serves, rather than on every instance's boot (keeps it off the cold-start path). It is - // convergent (reconciles the DB to the current model), so running it every deploy is correct. - override val preDeployTask: PreDeployTask = with(builder) { - path.path(tableName) bind PreDeployTask { - 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 @@ -120,18 +113,12 @@ public fun ?, T : HasId, ID : Comparable> Runtime = subscope?.let { auth.subscope(it) } ?: auth - override val tableDefinition: DatabaseTableDefinition = DatabaseTableDefinition(serializer, tableName) - // Table/index reconciliation runs as a pre-deploy task: once per deploy, before the new version - // serves, rather than on every instance's boot (keeps it off the cold-start path). It is - // convergent (reconciles the DB to the current model), so running it every deploy is correct. - override val preDeployTask: PreDeployTask = with(builder) { - path.path(tableName) bind PreDeployTask { - this@explicitModelInfo().prepare(tableDefinition) - } - } + // 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(tableDefinition) + override fun baseTable(): Table = registration() override val tableName: String get() = tableName 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) + } +} From 2e931cf4a9109a7bfdf5e1c1e99f39a94cb089f6 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 28 Jul 2026 08:37:10 -0600 Subject: [PATCH 09/11] Table name explicitness forced (cherry picked from commit 6998b45c9020bb66196ea42eb4b63f40e2a71d34) (cherry picked from commit 5f5f18e64eac6222e317e27d46c2cffb03dda3df) --- .../com/lightningkite/lightningserver/typed/ModelInfo.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 88049a3ad..537f485d7 100644 --- a/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt +++ b/typed/src/main/kotlin/com/lightningkite/lightningserver/typed/ModelInfo.kt @@ -47,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 }, @@ -99,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 }, @@ -152,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)) From 63ec83e4504a36e4281c696ed48d505c6d519fee Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Tue, 28 Jul 2026 13:03:59 -0600 Subject: [PATCH 10/11] Table name explicitness forced (cherry picked from commit 94057f37fa6ca1867707cf2fa7329142b23d19c3) (cherry picked from commit 8d4d61015f2f6d23cb42ac536266aeba532ace23) --- .../lightningserver/auth/index.md | 7 +- .../lightningserver/http/index.md | 7 +- docs-guide/drafts/advanced-database.md | 10 +- docs-guide/drafts/media.md | 42 ++++++--- docs-guide/drafts/model-realtime.md | 2 + docs-guide/drafts/notifications.md | 2 + docs-guide/drafts/oauth.md | 6 +- docs-guide/drafts/permissions.md | 10 +- docs-guide/drafts/pubsub.md | 4 +- docs-guide/drafts/vs-django.md | 4 +- docs-guide/drafts/vs-rails.md | 10 +- docs-guide/drafts/vs-spring.md | 14 +-- docs-guide/guide/caching.md | 6 +- docs-guide/guide/model-rest.md | 4 + docs-guide/guide/validation.md | 4 +- .../guide/samples/ModelRestSamples.kt | 1 + docs/authentication.md | 17 ++-- docs/autorest.md | 4 + docs/compared-to-django.md | 19 ++-- docs/compared-to-rails.md | 3 +- docs/compared-to-spring.md | 16 ++-- docs/database.md | 1 + docs/endpoints.md | 6 +- docs/media.md | 92 ++++++++++++------- docs/migration-v4-to-v5.md | 2 +- docs/sessions.md | 8 +- docs/typed-endpoints.md | 1 + .../files/UploadEarlySdkTests.kt | 1 + .../lightningserver/media/index.md | 27 ++++-- .../NotificationBulkDispatcher.kt | 1 + .../sessions/proofs/BackupCodeEndpoints.kt | 1 + .../proofs/KnownDeviceProofEndpoints.kt | 1 + .../sessions/proofs/PasswordProofEndpoints.kt | 1 + .../proofs/TimeBasedOTPProofEndpoints.kt | 1 + .../sessions/proofs/WebAuthNProofEndpoints.kt | 1 + .../lightningserver/typed/FunnelEndpoints.kt | 2 + .../typed/ModelRestEndpointsTest.kt | 1 + .../typed/ModelRestUpdatesWebsocketTest.kt | 1 + .../lightningserver/typed/sdk/server.kt | 3 + 39 files changed, 221 insertions(+), 122 deletions(-) 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 4093f7c9b..93cfd84f7 100644 --- a/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md +++ b/auth/src/main/kotlin/com/lightningkite/lightningserver/auth/index.md @@ -117,11 +117,14 @@ data class User( companion object : PrincipalType { override val idSerializer = Uuid.serializer() override val subjectSerializer = serializer() - val table = DatabaseTableDefinition() // define once, reuse everywhere + // 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(table).get(id) + return Server.userTable().get(id) ?: throw NotFoundException("User not found") } 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 e51ea6a53..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,12 +45,13 @@ includes types for requests, responses, headers, status codes, URL parsing, and ### Basic Request Handler ```kotlin -// Define the table once and share it; it is the key backends use to locate the table. -val userTable = DatabaseTableDefinition() +// 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(userTable).get(userId) + val user = userTable().get(userId) HttpResponse.json(user) } ``` diff --git a/docs-guide/drafts/advanced-database.md b/docs-guide/drafts/advanced-database.md index 10a9a0829..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(postTable) +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(postTable) +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(postTable) +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) + 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(postTable) +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 777cebbfd..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(productTable) - .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 bcee5f21f..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(userTable) + "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(userTable).findOne(condition { it.email eq value }) - ?: Server.database().table(userTable).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 b5ae4299c..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(userTable).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(postTable)` 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 1c42b904a..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(postTable).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(postTable) +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 493be690a..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(postTable) +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(userTable).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 bf3262edd..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(postTable) +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(userTable).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(userTable).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(userTable) + 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 d60e5c7d3..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(userTable).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(userTable).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(userTable) +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(userTable).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(userTable).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(userTable).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(userTable).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/caching.md b/docs-guide/guide/caching.md index 6ba476cfd..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(userTable).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(userTable).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/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 dc97f30c6..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(userTable).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/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 4dba8685b..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(userTable).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(userTable) + val existing = userTable() .findOne(condition { it.email eq value }) - existing ?: database().table(userTable) + 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(userTable).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(userTable).findOne(condition { it.email eq value }) - ?: database().table(userTable).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 ce7e312e9..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(postTable) +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(postTable) +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(postTable).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(postTable).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(postTable).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(postTable).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(postTable).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(postTable).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(userTable).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 ac390d351..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(postTable) +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 517b5cc57..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(tTable)` | 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(userTable).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(userTable).insertOne(request.toUser()) + userTable().insertOne(request.toUser()) } ) } @@ -161,7 +161,7 @@ data class User( ) : HasId // Usage - no interface to define! -val users = database().table(userTable) +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(userTable).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(userTable).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(orderTable).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(userTable).insertOne( + Server.userTable().insertOne( User(_id = Uuid.parse("..."), email = "test@example.com") ) diff --git a/docs/database.md b/docs/database.md index dd7bfa896..d18b057ab 100644 --- a/docs/database.md +++ b/docs/database.md @@ -93,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( diff --git a/docs/endpoints.md b/docs/endpoints.md index 10cac6f48..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(userTable) + 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(resourceTable).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(userTable).get(request.path.arg1) + val user = userTable().get(request.path.arg1) HttpResponse.json(user) } diff --git a/docs/media.md b/docs/media.md index 9489314ea..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(postTable) - .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(productTable) - .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(documentTable) - .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 index 5afa81203..a3153f668 100644 --- a/docs/migration-v4-to-v5.md +++ b/docs/migration-v4-to-v5.md @@ -115,7 +115,7 @@ 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 = database().table(userTable).count() +suspend fun countUsers(): Int = userTable().count() ``` This is the most pervasive source of migration compile errors: functions called from handlers, hooks, or signals diff --git a/docs/sessions.md b/docs/sessions.md index 6fdd5599f..511e276e0 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -127,11 +127,9 @@ object Server : ServerBuilder() { } ) - val passwordProof = PasswordProofEndpoints( - table = database.table(passwordSecretTable), - 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/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 80be0fae7..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,18 +40,25 @@ that meet or exceed requirements. ## Usage Example ```kotlin -context(runtime: ServerRuntime) object Server : ServerBuilder() { val database = setting("database", Database.Settings()) - val postTable = DatabaseTableDefinition() // define once, share across your app - - val posts = database - .table(postTable) - .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/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpoints.kt b/sessions/src/main/kotlin/com/lightningkite/lightningserver/sessions/proofs/BackupCodeEndpoints.kt index 45a4471a8..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) } ) 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 107f7a826..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 { 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 a756b4843..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)) 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/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/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt b/typed/src/test/kotlin/com/lightningkite/lightningserver/typed/ModelRestEndpointsTest.kt index 633fb1da5..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() } ) 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/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() } ) From 1a5280dffb1f78dd427340dd119fd60bedb909f4 Mon Sep 17 00:00:00 2001 From: UnknownJoe796 Date: Wed, 29 Jul 2026 13:42:09 -0600 Subject: [PATCH 11/11] Pass the now-required tableName where the explicitness pass missed it Making tableName required updated call sites in sessions, typed and files, but missed sessions-oauth, demo and the notifications tests. (cherry picked from commit 106ac7ba3ad1b0bb797a6b005c119c62be570659) --- .../com/lightningkite/lightningserver/demo/BlogEndpoints.kt | 1 + .../kotlin/com/lightningkite/lightningserver/demo/Server.kt | 1 + .../lightningkite/lightningserver/notifications/SyntaxTest.kt | 3 +++ .../lightningkite/lightningserver/notifications/TestHelper.kt | 1 + .../sessions/proofs/oauth/OauthClientEndpoints.kt | 1 + 5 files changed, 7 insertions(+) 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 0c5ff2cde..e019a91f5 100644 --- a/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt +++ b/demo/src/main/kotlin/com/lightningkite/lightningserver/demo/Server.kt @@ -147,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/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/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(