A-Z mode used a plain lexicographic compare, so "Chapter 10" sorted
before "Chapter 2". Switched to a natural-order comparator that
compares digit runs by numeric value, and added a TitleDesc mode so
title sort has the same asc/desc parity as sequence sort.
The static getString() call in loadSceneMetadata reaches
Toolkit.getScreenResolution() on Compose Desktop, which throws
HeadlessException on Linux CI and broke 8/9 integration tests.
RoundTripTestBase already stubbed StrRes for this exact reason;
this call was the one that bypassed it.
- Switch to FlatLaf (Darcula) for an IntelliJ-grade look and feel.
Lives in buildSrc only — never ships in the released app.
- Restructure the dialog into TitledBorder sections: Version,
Publish scope, Will push tag, Changelog.
- Replace the always-on checkbox grid with an All / Targeted scope
toggle. Full release (the common case) is now one click and shows
the bare vX.Y.Z tag; Targeted enables the per-store checkboxes.
- Make the computed tag the dialog's focal point — large monospaced
bold, italic + dimmed when no platforms are picked.
- Right-align Commit button, live-update window title to show the
version transition, bump frame size for more changelog room.
We've run into many problems where everything publishes fine, except 1 store. This provides a way to unblock that one store without pushing to the rest.
- Grant contents: write to the publish-fdroid-tag caller job so the
reusable workflow's git push tag actually has the token scope it
needs (reusable workflows can't elevate beyond the caller).
- Add a track input to publish-google-play (internal/alpha/beta/
production), default internal for manual dispatch so a stray
workflow_dispatch click can't ship develop straight to Production.
publish-release.yml explicitly passes track: production.
- Pass release_tag through env vars in publish-snap and
publish-fdroid-tag instead of interpolating into shell, closing
the workflow_dispatch command-injection vector.
- Validate fdroid release_tag matches semver before letting it near
git tag / git push.
The previous comment claimed the range assertion proved the edit
"round-tripped instead of being stamped with NOW() server-side." It
doesn't — server-side NOW() during sync falls in the same window.
Replace with an honest description: storeAllBuffers inside prepareForSync
bumps lastEdited right before upload, and the debounced contentFlow can
fire again post-sync, so client-bumped-during-sync and server-stamped-
on-receipt are observationally identical without instrumenting the sync
pipeline. The range check still rules out drops, epoch-0 resets, and
clearly-wrong stamps.
Lets us iterate on the Partner Center submission logic via
workflow_dispatch (with a dry_run mode that skips the final commit
and cleans up the draft) instead of cutting a release every time.
Release pipeline keeps the publish-google-play gate by calling the
reusable workflow with secrets: inherit.
Migrate the sync server's storage from SQLite to PostgreSQL with two
drivers: an in-process Zonky embedded server (the personal-install
experience) and a remote-Postgres mode (for multi-tenant deployments).
Single dialect, single set of .sq files, single set of DAOs.
The v1 schema bakes in Tier 1+2 cleanup: CITEXT for case-insensitive
columns, UUID for project ids, TIMESTAMPTZ everywhere, real BOOLEANs,
BIGSERIAL keys, partial indexes for sparse columns, and explicit ON
DELETE behavior plus CHECK constraints on every table.
A one-shot SQLite-to-Postgres migrator runs at startup against an
existing ~/hammer_data/server.db, copies it row-by-row in a single
transaction, verifies row counts in-transaction, then renames the
source to server.db.migrated-<ts>.bak. --migrate-dry-run lets operators
rehearse against a copy of production.
Verified:
- :server:test 409/0/0/0 (~2 min, embedded Postgres shared across the
JVM with truncate-between-tests)
- :integrationTests:jvmTest 9/0/0/0 (~54s)
- :desktopTest green via pre-commit hook
- Embedded-mode boot smoke against a fresh ~/hammer_data
- Remote-mode boot smoke against a Postgres 16 container, schema landed
cleanly with CITEXT/UUID/TIMESTAMPTZ promotions intact.
Notable carry-overs documented for ops:
- auth_token PK changed from `token` to (user_id, install_id); the
migrator collapses any historical duplicates via ON CONFLICT.
- One-shot migration is irreversible; an off-box backup of server.db
is the operator's job, not a replacement for the in-process .bak.
- 120MB of embedded Postgres binaries are now bundled in the server
jar (one of five platform artifacts is extracted at boot).
- Pre-commit hook now skips :server:test; CI should still run it.
Two failures came up running :integrationTests:jvmTest after the
SQLite-to-Postgres migration.
1. RoundTripTestBase seeded server_config with a raw epoch integer:
INSERT INTO server_config VALUES ('whitelist_enabled', 'false', 1704067200);
server_config.updated_at is now TIMESTAMPTZ; Postgres refused to
coerce the integer. Wrapped the value in to_timestamp(...).
2. SceneTimestampsTest's upload test asserted exact equality between a
pre-sync local lastEdited snapshot and the server's stored value.
SceneEditorRepository runs a debounced (~500ms) auto-save that calls
recordSceneActivity → bumps lastEdited on every Editor-source store,
and the storeAllBuffers pass inside sync's prepareForSync triggers
another. The exact-equality assertion happened to pass against fast
in-memory SQLite because the whole test finished inside the debounce
window; with the slower embedded Postgres it doesn't.
The test's intent is "edit timestamps round-trip to the server, not
stamped with NOW() server-side." Reframed the assertion as "server's
created and lastEdited both fall inside [beforeEdit, afterSync]" —
which is what the test actually proves.
Previously each test reset state by dropping and recreating the public
schema, which forced PostgresSchemaInitializer to re-run on every test:
CREATE EXTENSION citext, ServerDatabase.Schema.create(), ALTER COLUMN
... TYPE CITEXT/UUID for half a dozen columns, and the version-table
roundtrip. With ~400 tests that's a lot of DDL.
Now the schema is built once for the JVM (lazy in SharedTestPostgres)
and each test just does
TRUNCATE TABLE <all-user-tables> RESTART IDENTITY CASCADE
which drops to a single statement against pg_tables-discovered tables,
with sequences reset and FK chains cascaded.
Server test suite drops from ~4 minutes to ~2 minutes (409/0/0/0).
Also drop the now-dead createSchema and enforceForeignKeys constructor
parameters; nobody passed false to either.
- Replace dead MigrationParityChecker.check()/checkProject() stub with
checkAllCounts(copied, conn) that compares the copy phase's per-table
row counts against SELECT COUNT(*) for every table in-transaction. All
12 tables now covered, no SQLite re-reads.
- canonicalizeUuid now takes (table, pk, text) so malformed-UUID aborts
name the offending row in the error message.
- EmbeddedPostgresDatabase.initialize() stores the shutdown hook in a
field, refuses double-init, and close() removes the hook before
tearing the pool down — no more leaked hooks across re-init.
- Tighten narration-style comments in the migrator, the Postgres test
database fixture, and project.sq.
Replace ad-hoc icon scripts with a single source-of-truth manifest
(scripts/assets.yaml) and a Python generator that renders every app icon,
store-listing graphic, MSIX tile, favicon, and the Play feature graphic from
two SVG sources. Compositions (icon + "Hammer" wordmark in Kingthings
Trypewriter) are defined once as percentages of canvas and reused across the
Play feature graphic, MSIX wide/splash tiles, and the Snap featured banner.
Adds the missing snap store icon, fixes the snap desktop icon to the correct
256x256 hicolor size, and routes uploads-only assets to build/store-assets/
(gitignored).
See docs/ASSET-GENERATION.md for the manifest schema.
Code-review pass after the migration feature landed. Behavior unchanged;
all 409 server tests still green.
Migrator (SqliteToPostgresMigrator):
* Extract a single `copyTable(conn, sql, rows, bind)` helper. Every
per-table routine collapses to ~10 lines of binding logic, and the
BATCH_SIZE flush that only `copyStoryEntity` used now applies to
every table for free.
* Kill two N+1s in `copyAuthToken` and `copyDeletedProject` — both
were iterating every account and calling `getTokensByUserId` /
`getDeletedProjects` per row. Add `getAllForMigration` queries to
AuthToken.sq and DeletedProject.sq (matching every other legacy
table) and use them.
* Hoist `parseLegacyTimestamp` into `utilities/DateTimeUtils.kt`; the
parity checker now calls the shared one too.
* Drop the dead `@Suppress("unused")` import-pin properties and the
`kotlin.time.Clock` / `EmbeddedPostgresConfig` imports they pinned.
* Rename `countAnyTable` → `countAccountRows` (it only ever counted
one table; the name was lying about what it did).
* Drop the unnecessary `originalAutoCommit` save/restore — Hikari
resets every checked-out connection.
* Trim verbose narration comments throughout.
Migration tests:
* Drop the `dataDirName` parameter from `MigrationTestSupport.storageFor` —
it was always derived from `port` and never overridden by any caller.
* Add `MigrationFixtureBuilder.withSchema(dbPath, seed)` and use it
from both the smoke test and the malformed-UUID failure-mode test,
replacing the duplicated open-driver/Schema.create dance.
* Rewrite `SqliteToPostgresMigratorSmokeTest` to use
`MigrationTestSupport` + `MigrationFixtureBuilder.tiny` (was
inlining its own port allocator and seed helper, drifting from the
rest of the suite).
The server test suite now runs against embedded PostgreSQL and takes
~10 minutes per run — too slow to gate every commit. The hook now only
runs `desktopTest`. Run `:server:test` manually before pushing, or rely
on CI to catch server-side regressions.
Adds a fixture builder + supporting scaffolding + three test classes that
exercise the migrator end-to-end against real embedded Postgres:
* MigrationFixtureBuilder.tiny(path) seeds a v5 SQLite database with
every table populated — 2 accounts (admin + community author with
pen_name and bio), 1 project with 4 story_entities (one per type,
one encrypted), a deleted_entity, a project_data sync blob, public
+ password-protected project_access, a deleted_project, a live and
an expired auth_token, an unused password_reset_token, a white_list
row, a server_config row, a writing_activity row.
* MigrationTestSupport centralises the boilerplate: per-test port
allocation, `user.home` redirection so getRootDataDirectory() lands
under @TempDir, embedded-Postgres open/close.
* MigrationFullTableParityTest runs the migrator against the tiny
fixture and asserts row counts plus a spot-check on a representative
column per table — including UUID round-trip on project.uuid, the
`community_member` flag, encrypted vs unencrypted story entities,
and that the BIGSERIAL sequence advanced past max(id) so the next
auto-generated account id is 3. Also verifies CITEXT promotion took
effect by reading information_schema and confirming a case-shifted
`findAccount(EMAIL)` matches a lowercase row.
* MigrationFailureModesTest covers the three abort conditions:
Postgres pre-seeded → refuses cleanly, sqlite untouched; malformed
UUID → transaction rollback, Postgres empty, sqlite untouched;
dry-run → reports success but does not commit or rename.
* MigrationIdempotencyTest covers the two no-op conditions: no
`server.db` present, and `.bak` already present.
The CITEXT promotion in PostgresSchemaInitializer makes the column store
case-insensitively, but every production query that filtered on email,
pen_name, or project.name was silently still doing a case-sensitive
match. With LHS = citext and a bound `String` parameter sent as `text`
(or `varchar`), Postgres picks the `text = text` operator (via the
implicit `citext → text` cast) and the case-insensitive comparison is
lost.
Fix: cast every bound parameter against a CITEXT column to CITEXT
explicitly — `WHERE email = CAST(? AS CITEXT)` — so Postgres binds the
parameter to the `citext = citext` operator. Same shape as the existing
`CAST(? AS UUID)` plumbing for the uuid columns.
Also promotes `project.name` to CITEXT to match the SQLite-era
`LOWER(name) = LOWER(?)` semantics in `ProjectAccess.sq` (find-by-URL-
slug, the community feed). As a side effect, the `UNIQUE(name, user_id)`
constraint becomes case-insensitive too, which closes a latent gap where
two same-name-different-case projects could coexist but only one would
be findable.
Affected queries:
* Account.sq: findAccount, findAccountByPenName, isPenNameTaken
* WhiteList.sq: isWhiteListed, addToWhiteList, removeFromWhiteList,
updateReason
* ProjectAccess.sq: findPublicProjectByPenNameAndProjectName,
findProjectByPenNameProjectNameAndPassword, hasAnyAccessForProject,
getPublishedStoriesByPenName
* Project.sq: updateSyncData, findProjectByName
The migrator now fully streams every legacy SQLite table into PostgreSQL.
Previously password_reset_token, project, project_data, project_access,
story_entity, deleted_entity, server_config, and writing_activity were
stubs that silently returned 0 rows — a real production server.db would
have migrated successfully but landed in Postgres with most tables empty.
Implementation:
* Adds a `getAllForMigration: SELECT * FROM <table>` query to each
legacy `.sq` that lacked a getAll equivalent. These queries are
referenced only by the migrator and disappear when the legacy
SqlDelight tree is removed in a future release.
* auth_token uses `ON CONFLICT (user_id, install_id) DO UPDATE` to
handle the rare case where legacy data has multiple tokens for the
same install (the legacy PK was `token`, the new PK is the pair).
* story_entity streams in batches of 500 to bound peak memory on the
high-volume table.
* resetSequences switches to the 3-arg `setval(seq, value, is_called)`
form so empty tables get a sequence starting at 1, not 2.
The two `uuid` columns are now native PostgreSQL `UUID` (16-byte storage +
format validation) instead of `TEXT`. `PostgresSchemaInitializer` runs
`ALTER TABLE … TYPE UUID USING uuid::uuid` after `Schema.create()` for
both `project` and `deleted_project`.
Postgres won't auto-cast bound text parameters against a `UUID` column in
comparisons or against the UNIQUE index, so every WHERE/INSERT in
`Project.sq` and `DeletedProject.sq` that binds the column now goes through
`CAST(:uuid AS UUID)`.
Database-backed test fixtures that previously used placeholder strings like
`"Test UUID"` or `"project-id-1"` now use real canonical UUIDs:
* `ProjectEntityDatabaseDatasourceTest`
* `ServerProjectDataRepositoryTest`
* `ServerWritingActivityRepositoryTest`
* `ProjectsDatabaseDatasourceTest`
Mock-only tests retain their placeholder strings — those never reach
Postgres.
Test harness changes (necessary to make the full suite stable on Windows):
* `SqliteTestDatabase` now shares a single process-wide Zonky embedded
Postgres across every test. Each instance's `initialize()` drops and
recreates the `public` schema for isolation, then runs the production
`PostgresSchemaInitializer`. Spinning up a fresh embedded Postgres per
test method previously accumulated hundreds of postgres child processes
and hit Windows handle limits.
* `SqliteTestDatabase.initialize()` is now idempotent within an instance.
The production `Database` is initialized a second time by
`configureDependencyInjection` during Ktor startup, and that second call
must not wipe the fixture data the test just loaded.
`SqliteTestDatabase` now boots a per-instance Zonky embedded Postgres
(random free port) and runs the production `PostgresSchemaInitializer` so
test databases mirror the prod schema exactly. The class name is preserved
to minimize churn in dependent tests; the implementation is what changed.
Every test-side JDBC connection has `session_replication_role = replica`
applied on checkout, mirroring the old SQLite `foreign_keys=false` setting
that existing tests rely on for out-of-order fixture inserts (e.g.
`addDeletedProject` before the corresponding account is created). The
production `ProjectEntityDatabaseDatasource.deleteProject` now scrubs
`story_entity` rows explicitly rather than relying on FK CASCADE so behavior
is identical with FK enforcement on or off.
New `LegacyFixtureSqlTranslator` rewrites the existing
`src/test/resources/*/server.db.sql` fixtures on the fly:
* `INSERT INTO account`: 0/1 → FALSE/TRUE for the boolean columns.
* `INSERT INTO white_list` / `server_config`: epoch-seconds INTEGER →
`to_timestamp(N)` for the now-TIMESTAMPTZ columns.
* `INSERT INTO story_entity`: appends a NULL `cipher` value when the
fixture predates that column.
`FileResourcesUtils.setupDatabase` runs every statement through the
translator and resyncs each BIGSERIAL `id` sequence with `setval(...)` so
subsequent auto-id inserts don't collide with the explicit ids the fixtures
load.
`SqliteToPostgresMigratorSmokeTest` exercises the one-shot migrator end to
end against a real legacy SQLite file and a real embedded Postgres: seeds a
v5 SQLite database via `LegacySqliteDatabase.Schema`, drives the migrator
under a redirected `user.home`, and asserts row counts and the
`server.db.migrated-*.bak` rename.
Test files that previously passed SQLite-formatted date strings now build
`kotlin.time.Instant` directly. `DataMigratorTest` is removed — the SQLite
schema-migration path it exercised no longer exists at runtime; the
SQLite-to-Postgres data migration has its own dedicated test.
The server now persists its sync state to PostgreSQL, with two interchangeable
drivers selected by `storage.type` in the TOML config:
* EMBEDDED (default): in-process Zonky Postgres on a pinned port, data dir
`~/hammer_data/pgdata`. Preserves the "single JAR, no external services"
feel of the old SQLite setup for personal installs.
* REMOTE: HikariCP-pooled JDBC to an externally-managed Postgres for
multi-tenant deployments.
Schema is laid in at v1 with Tier 1 + Tier 2 features baked in:
* TIMESTAMPTZ AS Instant for every timestamp column (via
`InstantColumnAdapter`).
* BOOLEAN columns (was `INTEGER AS kotlin.Boolean`).
* BIGSERIAL primary keys.
* CITEXT extension promoted onto `account.email`, `account.pen_name`, and
`white_list.email` after `Schema.create()` for case-insensitive UNIQUE
indexes.
* Explicit `ON DELETE` per FK; partial UNIQUE index on `account.pen_name`;
partial index for community-author lookup; CHECK constraints for length
and `expires > created` invariants.
* `INSERT ... ON CONFLICT DO UPDATE` for upserts; insert-then-lookup
pattern replaces `last_insert_rowid()` triple-queries.
UUID promotion for `project.uuid` and `deleted_project.uuid` is deferred to a
follow-up release; existing tests pass placeholder strings that fail UUID
validation.
The legacy SQLite SqlDelight setup moves to
`src/main/sqldelight-legacy-sqlite/.../legacy/` and stays compiled as a
read-only `LegacySqliteDatabase` so the one-time migrator can stream rows
from it.
`SqliteToPostgresMigrator` runs at startup when a legacy `server.db` is
present alongside a Postgres-configured server: copies row-for-row inside a
single Postgres transaction with `session_replication_role = replica` to
suppress FK checks during the bulk insert, runs a row-count parity check on
the same connection, commits, and renames the source file to
`server.db.migrated-<timestamp>.bak`. A `--migrate-dry-run` CLI flag runs
the same flow but rolls back, for staging rehearsal.
Downstream consumers (DAOs, repositories, frontend pages) updated to use
`kotlin.time.Instant` directly instead of SQLite-flavored date strings.
Mirror the Mac setup with an :ios platform block exposing ios_testflight
and ios_release lanes, plus a publish-ios-app-store job in the release
workflow. Both lanes reuse the existing App Store Connect API key (the
.p8 isn't platform-specific). The CI job installs the provisioning
profile to both Xcode 16's path and the legacy Fastlane location for
compatibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Declare ITSAppUsesNonExemptEncryption=false so App Store Connect stops
asking on every upload, drop the legacy armv7 UIRequiredDeviceCapabilities
entry, and align CFBundleShortVersionString with the shared codebase
version (3.0.3) in gradle/libs.versions.toml. pbxproj now references the
freshly-issued "Hammer AppStore iOS" provisioning profile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS sandbox container UUIDs can change between installs, dangling the
absolute projectsDirectory persisted by a prior install. Decorate the
iOS GlobalSettingsDatasource to re-anchor the post-/Documents/ suffix
under the live Documents dir on load and persist the correction.
The Android and iOS EditorTopBar still wrapped the rename input in a
SimpleDialog + TextField + IconButton row, missing the § masthead,
hairline FormField, and Save/Cancel button row used by every other
dialog. Swap both for FormDialog/FormField with a § RENAME marker,
SCENE/GROUP/ROOT meta caption, and the same ProjectsRepository
validation pipeline as SceneRenameDialog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>