18 KiB
phase, plan, subsystem, tags, requires, provides, affects, actuals, plan_head_before, plan_head_after, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
| phase | plan | subsystem | tags | requires | provides | affects | actuals | plan_head_before | plan_head_after | tech-stack | key-files | key-decisions | patterns-established | requirements-completed | coverage | duration | completed | status | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-jobs-realtime-and-search-infrastructure | 05 | search |
|
|
|
|
|
0d45698ad7 |
9543e6508c |
|
|
|
|
|
|
24min | 2026-09-30 | complete |
Phase 11 Plan 05: beachcomber search sync and the Album Typesense binding Summary
A new framework package, beachcomber, syncs GORM models to a search index right after the write commits. It uses a hand-rolled Typesense engine on the Scout wire contract and never contacts Typesense while the search_use_typesense kill-switch is off or the API key is empty. Płytarium's Album documents match PHP toSearchableArray field for field and always carry a positive collection_id.
Performance
- Duration: 24 min
- Started: 2026-09-30T10:40:56Z
- Completed: 2026-09-30T11:05:16Z
- Tasks: 2
- Files modified: 14 (8 in summercms.go, 6 in fonoteka.go), plus deferred-items.md
Accomplishments
- beachcomber core (D-19).
- The contracts:
Searchable,IndexSchemaProvider,SearchKeyer,Engine,QueryandGate/GateFunc. - Engines come from an init-time registry:
RegisterEnginepanics on a duplicate name, and the built-innullengine is the default.Fromreadssearch.driverandsearch.prefix; an unknown driver fails boot and lists the registered engines. - The callbacks are installed through
lagoon.OnDatabasewith Register-or-Replace per*gorm.DB.SyncandRemoverun the same gated path on demand for reindex tooling.
- The contracts:
- After-commit sync (D-20).
- Each Searchable row with a primary key registers work with
lagoon.AfterCommit. - The work runs three request-free gates in order: the engine is configured, a database is published, the application Gate is on.
- It then reloads the row with
Unscoped(). A delete, a missing row, a soft-deleted row orShouldBeSearchablefalse sends a delete; otherwise the document is upserted. - Every error, timeout or panic becomes one Warn log with index, key and operation. The write stays committed.
- Each Searchable row with a primary key registers work with
- Typesense engine.
- Every request carries
X-TYPESENSE-API-KEY. - Upsert reads the collection, creates it from the schema plus
nameon 404, then imports JSON lines withaction=upsert. Anysuccess:falseanswer line is an error. - Delete and Flush treat 404 as success.
SearchIDsreturns candidate ids. - Every request is bounded by the timeout. A non-2xx answer is a
StatusErrorthat carries no answer body.
- Every request carries
- fonoteka.go.
- The Album port covers
AlbumMedia,MediumFamily,SearchableAs,ToSearchableArray(17 PHP keys and types; styles ordered by name, artists by pivotsort_order; refuses collection_id 0) andSearchIndexSchema. settingsGatereadsgolem15_fonoteka_settings. Any read error or a missing row counts as off.- Boot calls
wireSearch. config/search.yamlholds the Scout defaults, and the README mapsSCOUT_*andTYPESENSE_*toSUMMER_SEARCH__*.
- The Album port covers
Task Commits
summercms.go:
- Task 1: after-commit upsert with collection_id (tracer):
3e1f3e6(feat) - Task 2: deletes, failures, transaction paths, search ids:
9543e65(fix: callback ordering, clean session,StatusError, README semantics)
fonoteka.go:
- Task 1:
c4a7449(feat: Album Searchable, settings gate, wiring, config,TestAlbumSearchSmoke) - Task 2:
3b32946(test:TestAlbumSearchDeleteAndFailures),0ce04ab(docs: README env mapping)
TDD Gate Compliance (Task 2)
-
RED:
TestAlbumSearchDeleteAndFailureswas written against the Task 1 code. It failed on 6 of its 10 subtests with target assertions, not load errors:plain create was not synced: []no import inside the plain transaction: []Upsert error … status 500 does not expose status 500- soft delete,
success:falseand zero-collection_id warnings missing, because those writes never synced
The evidence record was checked with
gsd-tools check tdd-red-evidenceand returnedRED_EVIDENCE_OK(targetTestAlbumSearchDeleteAndFailures, 15 tests, 7 failing). The go test output was converted to surefire XML for the checker. Tests and code land in one commit, as green-at-every-commit requires. -
GREEN:
9543e65makes all 13 subtests of both tests pass under-race. -
REFACTOR: none needed.
Files Created/Modified
modules/beachcomber/searchable.go: the model, engine, query and gate contractsmodules/beachcomber/engines.go: engine registry and thenullenginemodules/beachcomber/beachcomber.go:Service,From,SetGate,IndexName, callback installationmodules/beachcomber/sync.go: callbacks, after-commit registration, gates, reload, savepoint, clean session,Sync/Removemodules/beachcomber/typesense/config.go,engine.go:search.typesense.*config and the HTTP enginemodules/beachcomber/README.md,README.md: module docs and the root modules row../fonoteka.go/plugins/golem15/fonoteka/models/album_search.go: Album document, schema and medium map../fonoteka.go/plugins/golem15/fonoteka/search.go:settingsGate,wireSearch, the typesense import../fonoteka.go/plugins/golem15/fonoteka/plugin.go: Boot callswireSearch../fonoteka.go/config/search.yaml,../fonoteka.go/README.md: config and env mapping../fonoteka.go/plugins/golem15/fonoteka/search_smoke_test.go: the fake Typesense and both smoke tests
Decisions Made
See key-decisions in the frontmatter. The most consequential are the two GORM findings (callback ordering and statement cloning) that made single-statement and plain-transaction writes silently skip sync in the Task 1 code.
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] Sync callbacks ran after GORM's own commit
- Found during: Task 2 (RED run)
- Issue:
After("gorm:after_create")alone makes GORM append the callback to the end of the chain, pastgorm:commit_or_rollback_transactionandlagoon:after_commit. On a single-statement write,lagoon.AfterCommitbuffered the work after the flush had already run, so nothing synced. The Task 1 smoke test only usedlagoon.Transactionand missed it. - Fix: each callback also declares
Before("gorm:commit_or_rollback_transaction"). - Files modified:
modules/beachcomber/beachcomber.go - Verification: subtest "a plain create syncs after its implicit commit"
- Committed in:
9543e65
2. [Rule 1 - Bug] The gate read the settings row through the Album statement
- Found during: Task 2 (RED run, debug prints)
- Issue: on a callback's handle,
db.Session(&gorm.Session{NewDB: true, Context: ctx})clones the write's statement. The gate'sWithContextcontinued from that clone, so it queried with the Album model and table and read an album row (id 2,false), which counted as off. Plain-transaction and single-statement writes were skipped silently. - Fix:
cleanSession(Session, thenClauses(), thenSession{NewDB}) gives the gate and the document builder an empty statement on the same connection. - Files modified:
modules/beachcomber/sync.go,modules/beachcomber/searchable.go(Gate doc) - Verification: subtests for the plain transaction, the implicit commit, soft delete and zero collection_id
- Committed in:
9543e65
3. [Rule 2 - Missing critical] Savepoint around sync reads inside a caller's plain transaction
- Found during: Task 1
- Issue: a failed gate or reload read (for example no settings table yet) inside a plain
gormtransaction would abort the caller's Postgres transaction, and the write would fail because of search. - Fix:
inSavepointwraps the reads and rolls back to the savepoint on error, the same as lighthouse. - Files modified:
modules/beachcomber/sync.go - Committed in:
3e1f3e6
Total deviations: 3 auto-fixed (2 bugs, 1 missing critical).
Impact on plan: all three were needed for the D-20 behaviour the plan requires. No scope creep. Two related latent issues in 11-01 and 11-03 code are logged in deferred-items.md and were not fixed:
- lighthouse broadcast callbacks have the same After-only ordering;
- lagoon's after-commit handle carries the write's statement.
Issues Encountered
gsd-tools check tdd-red-evidencereads surefire XML with the regexname="…", which first matches insideclassname="…". A<testcase>must listnamebeforeclassname, or the target is never matched. This was worked around in the evidence record; it is not a project issue.- A
cpaliased tocp -iin the shell hung one debug command;/bin/cp -fwas used afterwards. No repository impact.
Known Stubs
None. Album.ShouldBeSearchable returning true is intentional; the settings Gate is the kill-switch, as the plan's flagged assumption states.
Threat Flags
None. The only new trust boundary is the outbound Typesense client, which is covered by T-11-07, T-11-25, T-11-26 and T-11-27. No inbound endpoint was added.
User Setup Required
None for tests. To index in a deployment, set SUMMER_SEARCH__TYPESENSE__API_KEY (and the host and port if they differ from localhost:8181), then turn on search_use_typesense in the admin settings.
Next Phase Readiness
- Phase 12 can query
svc.Engine().SearchIDs(ctx, svc.IndexName(&models.Album{}), beachcomber.Query{QueryBy: models.AlbumSearchQueryBy, FilterBy: "collection_id:=…"}). It must re-gate the ids in SQL. - Bulk and CSV paths (Phase 13) should call
Service.Syncper row. Statements without a primary key are not synced. - Plan 11-07 (unit tests) should pick up both
deferred-items.mdentries and add engine-level tests fortypesense.Enginewithout Postgres.
Self-Check: PASSED
- All 11 key files exist on disk.
- Commits found:
3e1f3e6and9543e65in summercms.go;c4a7449,3b32946and0ce04abin fonoteka.go. - Task 1 and Task 2 acceptance criteria were re-run and all pass.
- Plan verification passed:
- summercms.go:
go vet ./... && go test ./...is green. - fonoteka.go: the full vet and test command is green.
TestAlbumSearchSmokeandTestAlbumSearchDeleteAndFailuresreport--- PASSunder-race, with no SKIP and no DATA RACE.
- summercms.go:
Phase: 11-jobs-realtime-and-search-infrastructure Completed: 2026-09-30