Skip to content

PerfectTemplate 4.0.0: Swift 6 reference app on the released Perfect stack - #25

Merged
taplin merged 16 commits into
mainfrom
swift6-modernization
Oct 15, 2026
Merged

taplin merged 16 commits into
mainfrom
swift6-modernization

Conversation

@taplin

@taplin taplin commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

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 (for NIOPosix's thread pool). No branch dependencies.
  • Package.resolved is 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

  • Config from environment: 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 bare swift run never touches a MySQL on 3306. Ports are validated (1–65535); a bad value exits with a clear message.
  • Routes: GET /api/health, GET|POST /api/posts, GET|PUT|DELETE /api/posts/:id, plus /, /posts and /static/* (GET and HEAD). /api/dbsetup is gone; the table is created on the first successful connection.
  • Database work runs on a dedicated 16-thread 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.
  • Timeouts: connect 5 s, read/write 30 s, and MySQL's own lock and statement limits at 20 s, so the server gives up before the client does. A timed-out request answers 503 "Database busy." and nothing is written. During an outage requests fail fast with 503 for 2 s after a failed connect instead of queuing.
  • API behaviour: UUID ids; createdAt is a DATETIME, returned as ISO 8601; the list is newest first with id as tiebreak. PUT updates only the fields sent (concurrent title/body updates no longer overwrite each other). POST/PUT require Content-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.
  • Logging: Perfect-Logger to console and file; user text is logged ASCII-escaped; CRUD's SQL goes through the logger at debug level, one line per statement; connect failures are rate-limited.
  • Web UI: no inline event handlers (ids travel in data-id with one delegated listener); all values are escaped; body whitespace is preserved on edit.

CI

.github/workflows/ci.yml follows Perfect-CRUD's: Linux Swift 6.2/6.4 (noble) with a MySQL 8.4 service and the full Scripts/smoke-test.sh round trip; macOS Xcode 26.0.1/26.6 with the no-database smoke test; debug and release, warnings as errors.

Verification

  • Builds warning-free with Xcode 27 (Swift 6.4) and Xcode 26.3 (Swift 6.2.4), debug and release, from a fresh clone with the committed Package.resolved. The only warning is Homebrew libmysqlclient's ld deployment-target warning.
  • Linux swift:6.2-noble and swift:6.4-noble on arm64 and swift:6.4-noble amd64: builds, full and no-db smoke tests against MySQL 8.4; MariaDB 11.8 smoke tests on macOS and Linux.
  • Stress and edge cases: 64–128 requests blocked on a table lock (health stays ~1–16 ms), lock-then-write (503 at ~20 s, nothing committed, no leftover sessions), 100 concurrent partial PUT pairs (0 lost), date round trips across time zones, Unicode title edge cases, XSS payloads in the browser, path traversal (400).

🤖 Generated with Claude Code

taplin and others added 16 commits September 1, 2026 10:23
…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>
@taplin taplin changed the title Swift 6 modernization: end-to-end reference app on the modernized stack PerfectTemplate 4.0.0: Swift 6 reference app on the released Perfect stack Oct 15, 2026
@taplin
taplin merged commit 7b3a5c3 into main Oct 15, 2026
16 checks passed
@taplin
taplin deleted the swift6-modernization branch October 15, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant