Repository navigation
PerfectTemplate 4.0.0: Swift 6 reference app on the released Perfect stack - #25
Merged
Merged
Conversation
…nce app Replaces the old single-file Perfect 3-era starter (Sources/PerfectTemplate/main.swift) with a full Posts CRUD reference app on the modernized stack. LICENSE/LICENSE.zh_CN carried forward unchanged from the original; README.zh_CN.md dropped since it would otherwise be left silently out of sync with the fully rewritten English README.
Perfect-NIO 0.1.0, Perfect-CRUD 3.1.0, Perfect-MySQL 4.0.0 and Perfect-Logger 4.0.0 replace the `branch: "main"` pins. The unused PerfectNIOCRUD product is dropped from the target. Package.resolved is removed for now: it pinned branch revisions, and it cannot be regenerated until Perfect-Logger 4.0.0 is tagged. It should be regenerated and committed again then, so a copy of this example builds against the exact versions it was tested with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Config
- Add DB_PORT. Database defaults now match the README quick start (a
MySQL container published on host port 13306, user/password
"perfect"), so a bare `swift run` never connects to, and creates
tables in, an unrelated MySQL on 3306, and the example no longer
defaults to root with an empty password.
- Add WEBROOT for running from a directory other than the package root.
- Empty environment variables count as unset.
Database access
- withDB {} opens a connection, ensures the schema and runs the work on
a dispatch queue, so blocking libmysqlclient calls stay off Swift's
cooperative thread pool. Still one connection per request: simple,
and it recovers by itself when MySQL restarts.
- A failed connection answers 503 "Database unavailable." instead of a
500. The schema is created on the first successful connection, so a
server started while MySQL was down works once it comes up.
- Drop GET /api/dbsetup: it changed state on a GET, and the lazy schema
setup makes it unnecessary.
- Listing uses select().all(): iterating select() as a Sequence logged
query errors and returned [] (a missing table listed as no posts).
API
- Titles are trimmed, required, at most 200 characters and one line;
bodies are optional and at most 20,000 characters. 400 otherwise.
- Ids are UUIDs; anything else is a 404 without a query. PUT and DELETE
of an unknown id are 404 (DELETE used to report success).
- Posts store createdAt as a Date (DATETIME) and list newest first;
responses encode dates as ISO 8601 strings.
- Titles are logged with String(reflecting:), so user input can't
start a new log line.
- The server caps request bodies at 256 KiB (Perfect-NIO answers 413);
its default is 10 MiB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- posts.html passes post ids in data-id attributes with one delegated click listener, instead of inline onclick JavaScript built from server data (HTML escaping alone does not make a value safe inside a JavaScript string in an attribute). The formatted date is escaped too, and ids in request paths are URI-encoded. Inputs carry the server's length limits. - index.html used port 8080 in its examples (the app listens on 9191), had a quick start that assumed a local root MySQL, and did not link to the posts page. It now matches the README quick start. - Add webroot/static/favicon.svg, used by both pages, so the /static route serves something. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scripts/smoke-test.sh drives a running server: pages, health, and a create/list/get/update/delete round trip with the 400 and 404 cases. With --no-db it checks a server without MySQL (health 200, /api/posts 503). CI follows Perfect-CRUD's matrix without the iOS job: - Linux: swift:6.2-noble and 6.4-noble, debug and release, with libmysqlclient-dev; the app runs against a MySQL 8.4 service and the full smoke test. - macOS: Xcode 26.0.1 and 26.6, debug and release, with Homebrew's mysql-client as Perfect-MySQL's CI installs it; GitHub's runners have no MySQL, so the no-database smoke test. Builds use -warnings-as-errors. Homebrew's libmysqlclient makes ld warn that it targets a newer macOS than 12; that is a linker warning, which -warnings-as-errors (a Swift compiler flag) doesn't promote, so the build passes, as in Perfect-MySQL's CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents Swift 6.2+, macOS 12+ and Linux, the client library for each, dependency versions, every environment variable (including DB_PORT and WEBROOT), the routes and their error statuses, and a quick start that runs MySQL 8.4 in a container on host port 13306, matching the app's defaults. /api/dbsetup is gone from the route list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- withDB runs on a dedicated 16-thread NIOThreadPool (swift-nio NIOPosix, already in the graph through Perfect-NIO) instead of a concurrent DispatchQueue. On Linux that queue shared libdispatch's threads with Swift's global executor, so requests stuck in MySQL (a locked table, an unroutable DB_HOST) stalled /api/health and the pages too. Now at most 16 requests wait on MySQL and the rest queue; with 64 requests blocked on LOCK TABLES, health and pages still answer in about 2 ms. - connect() configures the MySQL handle itself: utf8mb4, a 5 s connect timeout and 30 s read/write timeouts, then wraps it with MySQLDatabaseConfiguration(connection:). - Connection failures are logged at most once per 10 s, with a count of the ones suppressed. - The startup connection runs in a detached task, so the server listens at once even when MySQL is slow or unreachable. - "serving on" is logged from withServer, after the bind succeeded; a failed bind is logged and exits 1 instead of trapping. - PORT and DB_PORT must be whole numbers from 1 to 65535; anything else stops the app with a message (DB_PORT=-5 or 5000000000 used to trap, and non-numbers silently fell back to the default). - PerfectCRUD's SQL and error printing to stdout goes to the log at debug level instead. - Warn at startup when $WEBROOT/index.html is missing. - Note that a dropped table needs an app restart (Schema.ready is cached). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PUT writes only the fields it was sent (update(_:setKeys:)), skips
the write for {}, and returns the row as read back. Updating the
whole row put back the other field as read earlier, so concurrent
title and body updates lost one of them (90 of 100 pairs before,
0 of 100 now).
- POST and PUT answer 415 unless Content-Type is application/json, so
another site can't create posts with a plain form or a text/plain
fetch, which browsers send cross-site without a preflight.
- Titles also refuse U+2028/U+2029, the bidi embedding, override and
isolate controls (U+202A-202E, U+2066-2069), and titles made only of
invisible characters (a lone BOM or zero-width space). Other format
characters stay allowed, since emoji sequences need the ZWJ.
- Titles and bodies are also capped in Unicode scalars (8 per allowed
character), so a combining-mark flood counts against the limit.
- Titles are logged with every non-ASCII scalar escaped.
- DELETE deletes in one statement and checks the affected row count
(PerfectCRUD's dynamic mutation API), instead of count-then-delete.
- The list orders by createdAt, then id, newest first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The page and static routes accept HEAD as well as GET; Perfect-NIO sends GET's headers without the body. HEAD used to be a 404. - posts.html no longer trims the body when saving, and drops the maxlength attributes: browsers count UTF-16 units while the server counts characters, so an existing post could become uneditable. The server's limit message shows in the error bar instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- README: the database thread pool and timeouts, 415 and HEAD, what 400 and 503 actually mean, port validation, PUT writing only the fields it gets, debug-level SQL logging, restarting after dropping the table, swift-nio in the dependency table, and an absolute link to the legacy branch. - CI sets TZ=America/Denver on both jobs, as Perfect-MySQL's does, so date handling runs away from UTC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 30 s client read timeout didn't stop MySQL: a statement stuck behind a table lock still committed after the request had returned 500, so a retry duplicated the post, and abandoned sessions piled up. - Every connection runs an init command setting lock_wait_timeout and innodb_lock_wait_timeout to 20 s and max_execution_time to 20,000 ms, so MySQL abandons the statement before the client stops waiting. - Lock wait timeouts (1205) and max_execution_time (3024) answer 503 "Database busy." instead of 500. Perfect-MySQL's error carries only the server message (the connection's errno is already reset), so they are recognised by MySQL's message text. - After a failed connection, requests answer 503 at once for 2 s instead of each waiting up to 5 s to connect again. - PerfectCRUD log events have their whitespace collapsed, so a multi-line CREATE TABLE stays on one log line. - A failed bind is logged as "could not start: ...". With LOCK TABLES Post WRITE held for 60 s, 3 POSTs and a DELETE now return 503 after 20 s, write nothing, and leave no app sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Titles: at most 200 characters and, separately, 2,000 Unicode code
points. Bodies: 20,000 characters and 100,000 code points, which raw
UTF-8 text fits in the 256 KiB request limit. Each limit has its own
message ("Title is too long (at most 2,000 Unicode code points)."),
with numbers grouped the same way in every locale. This replaces the
8-scalars-per-character cap, which refused 200 emoji families.
- DELETE takes the table and primary key names from
Post.CRUDTableStructure() instead of hard-coding "Post" and "id".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Perfect-Logger 4.0.0 is tagged, so the pins resolve: Perfect-NIO 0.1.0, Perfect-CRUD 3.1.0, Perfect-MySQL 4.0.0, Perfect-Logger 4.0.0, swift-nio 2.104.0 and swift-log 1.16.1. A copy of the example now builds against the versions it was tested with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MYSQL_INIT_COMMAND now sets only lock_wait_timeout and innodb_lock_wait_timeout, which both servers have. After connecting, the app sets MySQL's max_execution_time (ms, SELECTs only) and, if the server doesn't know it, MariaDB's max_statement_time (seconds, every statement). Every connection to mariadb:11.8 used to fail with "Unknown system variable 'max_execution_time'". - MariaDB's ER_STATEMENT_TIMEOUT (1969, "max_statement_time exceeded") also answers 503 "Database busy.". - The busy texts only count in a MySQLCRUDError whose description starts with Perfect-MySQL 4.0.0's "Error executing statement. " or "Error reading results. ", the prefixes it gives errors the server reported. A decoding error quoting a stored "Lock wait timeout exceeded" is a 500 again, not a 503. - "could not start" is logged only when the server never listened; a later failure logs "server stopped". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the 2017 single-file starter with a reference app for the Swift 6 Perfect stack: a small "Posts" JSON API and browser UI on Perfect-NIO, Perfect-CRUD, Perfect-MySQL and Perfect-Logger, pinned to released versions. This is wave 4 of the ecosystem release and is intended to be tagged 4.0.0 after merge.
The first commit (0843ee9) is the original modernization from this PR. The commits after it bring it to release quality after four rounds of adversarial review.
Dependencies
from:pins: Perfect-NIO 0.1.0, Perfect-CRUD 3.1.0, Perfect-MySQL 4.0.0, Perfect-Logger 4.0.0, and swift-nio 2.65.0 (forNIOPosix's thread pool). No branch dependencies.Package.resolvedis committed (example apps are meant to be copied and built reproducibly).swift-tools-version: 6.2, macOS 12+ and Linux. Servers: MySQL 5.7.8+ or MariaDB 10.1+.App
PORT,DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASS,WEBROOT,LOG_FILE,LOG_LEVEL. Defaults match the README's container quick start (MySQL on host port 13306), so a bareswift runnever touches a MySQL on 3306. Ports are validated (1–65535); a bad value exits with a clear message.GET /api/health,GET|POST /api/posts,GET|PUT|DELETE /api/posts/:id, plus/,/postsand/static/*(GET and HEAD)./api/dbsetupis gone; the table is created on the first successful connection.NIOThreadPool, never on Swift's cooperative pool. Without this, a slow or unreachable MySQL on Linux made the whole server (including/api/health) stop answering at about 32 blocked requests.createdAtis a DATETIME, returned as ISO 8601; the list is newest first with id as tiebreak.PUTupdates only the fields sent (concurrent title/body updates no longer overwrite each other).POST/PUTrequireContent-Type: application/json(415 otherwise). Titles: 1–200 characters, one line, no control, line-separator or bidi-override characters, at most 2,000 code points; bodies: at most 20,000 characters / 100,000 code points; request bodies capped at 256 KiB (413). Errors map to 400/404/413/415/503/500 without leaking internal text.data-idwith one delegated listener); all values are escaped; body whitespace is preserved on edit.CI
.github/workflows/ci.ymlfollows Perfect-CRUD's: Linux Swift 6.2/6.4 (noble) with a MySQL 8.4 service and the fullScripts/smoke-test.shround trip; macOS Xcode 26.0.1/26.6 with the no-database smoke test; debug and release, warnings as errors.Verification
Package.resolved. The only warning is Homebrew libmysqlclient's ld deployment-target warning.swift:6.2-nobleandswift:6.4-nobleon arm64 andswift:6.4-nobleamd64: builds, full and no-db smoke tests against MySQL 8.4; MariaDB 11.8 smoke tests on macOS and Linux.🤖 Generated with Claude Code