Skip to content

fix(db): databaseSchema alone puts the tables in that schema - #346

Merged
rcardin merged 2 commits into
devfrom
issue-315
Oct 22, 2026
Merged

rcardin merged 2 commits into
devfrom
issue-315

Conversation

@rcardin

@rcardin rcardin commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Why

An application that overrides databaseSchema without also writing a databaseInit that sets search_path splits in two: every catalog read (status, sync, migrate, the drift gate, deploy check) looks in the named schema, while the DDL and the migration ledger land in public. sync --apply exits 0, then status reports the whole model missing and exits 1, forever, with no warning. Every example carried the same eight line hook to dodge this, and the docs taught that both halves were needed.

What

Naming the schema is now enough. databaseSchema moved from DbApp into DbInit, where the Database is built, with the same name and the same public default, so existing overrides compile unchanged. The pool now runs one composed hook:

 trait DbInit {
+  def databaseSchema: String = "public"          // moved here from DbApp
   def databaseInit: Connection -> Unit = _ => () // default still a no op
   def database =
     Database.connect(url, user, password,
-      databaseInit,
+      DbInit.connectionInit(databaseSchema, databaseInit),
       poolSize, acquireTimeout)
 }

Observable behaviour:

  • databaseSchema = "public" (the default): nothing runs on the connection, Postgres's own search_path stays.
  • Any other name: each physical connection creates the schema if the catalog says it is missing, then sets search_path to that schema alone, then runs the application's databaseInit.
  • An application that overrides databaseInit (for application_name, say) needs no super call to stay in its schema.
  • Behaviour change, deliberate: an application that overrode databaseSchema but on purpose never set search_path (catalog reads only) now has its connections switch to that schema.

The four examples (blog, shop, todo, reminders), the blog and shop CreateUser tools, the shop ApiSuite and blog PostOwnershipSuite test apps, and the Tour drop their schema hook. The how to and reference pages describe databaseSchema alone.

How

DbInit.connectionInit composes the schema step with the user's hook:

connectionInit(schema, init)
  if schema == "public"
    return init                         // exact string compare; "PUBLIC" is a different identifier
  return c =>
    name = Ddl.q(schema)                // "…" with embedded " doubled
    if not exists (select 1 from pg_namespace where nspname = ?)
      try   create schema if not exists name
      catch SQLState 23505 or 42P06     // lost the race to another connection: schema is there
    set search_path to name
    init(c)                             // user's hook last, so it can still override search_path

Alternatives it beat:

  • A derived databaseInit default that overriders chain with super.databaseInit(c). Rejected because any override that forgets super brings the same silent split back, which is the trap this issue removes. The issue's "databaseSchema becomes the one override" is met through composition, not through a derived default.
  • A bare create schema if not exists on every connection. Rejected because Postgres checks CREATE on the database before honouring IF NOT EXISTS, so a least privileged role that owns an existing schema would fail every pooled connection (the deployed shop is such a case). The cost is one catalog read per physical connection.
  • Qualifying DDL with the schema name. Out of scope, as the issue says.

How to review

  1. modules/db/src/main/scala/io/eezo/db/DbInit.scala: the heart. Check connectionInit order (schema step, then user hook), that the public branch returns init untouched, that the closure captures only the schema parameter and not this (capture checking, no suppression), and the CreatedMeanwhile catch: only 23505 and 42P06 are swallowed, anything else fails the connection.
  2. modules/db/src/main/scala/io/eezo/db/schema/Ddl.scala: q widened from private to private[db] so DbInit reuses it. No public signature changes.
  3. modules/db/src/main/scala/io/eezo/db/DbApp.scala: databaseSchema removed here and inherited from DbInit, along with its stale comment about search_path being set in databaseInit.
  4. modules/db/src/test/scala/io/eezo/db/DbInitSuite.scala: start from the double quote test, which pins quoting on the generated statements through a recording fake JDBC connection, then the race and refusal tests.
  5. modules/db/src/test/scala/io/eezo/db/DbAppSuite.scala: the three Postgres cases. Check inFreshSchema drops the schema before and after, and the least privileged role case drops its role.
  6. modules/db/src/test/scala/io/eezo/db/support/Pg.scala: scaladoc only; Pg.searchPath still isolates DbSuite and PgSuite.
  7. modules/example/src/test/scala/example/Tour.scala: the hook is replaced by override def databaseSchema = Schema0, which also fixes the Tour's own commands reading public.
  8. The examples' Main.scala, CreateUser.scala and test apps: the hook and the java.sql.Connection import are gone, nothing else.
  9. docs/how-to/configure-the-database.md, docs/reference/configuration.md, and the blog, todo and reminders READMEs: no page still says both halves are required. The todo README diff is mostly its old em dashes rewritten, which the per file prose check forced once the file was touched.

The author must confirm:

  • Decision, databaseSchema alone is enough: taken, as the issue recommends.
  • Decision, super versus composition: composition, through DbInit.connectionInit; an override never needs super, and the user's hook runs second so it can still set search_path on purpose.
  • Decision, where databaseSchema lives: moved into DbInit, same name, same public default; DbApp's live read and EezoApp's DriftGate call untouched.
  • Decision, the public default: no statement at all, by exact string compare.
  • Decision, what search_path is: the application's schema alone, public not appended; extensions' objects in public are off the path for any application that names a schema, and the how to says so.
  • Decision, create always or only when missing: catalog check in pg_namespace first, IF NOT EXISTS kept, plus the race catch for two connections that both found it missing.
  • Decision, identifier quoting: through the existing Ddl.q helper (doubles embedded double quotes), not raw interpolation.
  • Concern: quoting is proven at the level of the generated statements with a fake connection, not with a quote containing name against real Postgres.
  • Concern: the race handling (CreatedMeanwhile) and its two DbInitSuite tests are in the diff but not in the implementer's report; check they match your reading of the risk.
  • Reviewers' note: the quoting helper is Ddl.q, widened from private to private[db], not a new DbInit.identifier. This touches Ddl.scala, which the contract lists as unchanged in signature; the change is visibility only, inside the db package.

How it was tested

Added, each seen failing before the change it covers:

  • DbAppSuite "databaseSchema alone puts the tables in that schema, creating it, and status agrees": a DbApp over Library naming a schema that does not exist yet, no hook; sync --apply exits 0, author is only in that schema per information_schema, status exits 0.
  • DbAppSuite "a databaseInit of the application's own, without super, keeps the tables in databaseSchema": the same with an application_name hook that never calls super.
  • DbAppSuite "a role that owns its schema but cannot create one on the database still connects": pins the catalog check against the CREATE privilege trap.
  • DbInitSuite "the default public schema runs nothing on the connection": zero calls on the connection.
  • DbInitSuite "a named schema is created when missing, then becomes the search_path, before the hook": statement order.
  • DbInitSuite "a schema that exists is not created again": only set search_path runs.
  • DbInitSuite "losing the race to create the schema to another connection still enters it, either SQLState": 23505 and 42P06.
  • DbInitSuite "any other failure to create the schema fails the connection": 42501 propagates.
  • DbInitSuite "a double quote in the schema name is doubled, so the name stays one identifier".

Existing suites relying on the default search_path (DbSuite and PgSuite through Pg.searchPath, CommandsSuite) pass unchanged. The Tour runs to the end with TOUR_NOPAUSE=1.

bin/gate on the committed tree: GATE GREEN, db 320 passed, examples blog 13 and shop 11 passed, site 49 passed, every other module green. bin/prose-check is clean on every changed file.

Review status

The review loop converged: round 1 returned verdict clean with no findings.

Closes #315

🤖 Generated with Claude Code

rcardin and others added 2 commits October 22, 2026 16:59
An application that named a schema through databaseSchema but did not
also write a databaseInit hook split in two: the drift commands read the
named schema while the DDL and the migration ledger landed in public, so
sync applied cleanly and status reported the whole model missing forever.

databaseSchema now lives in DbInit, where the Database is built, and the
pool runs one hook composed of the schema step and the application's own
databaseInit. The schema step reads pg_namespace, creates the schema only
when it is missing, so a role without CREATE on the database still
connects, and sets search_path to the quoted name. The public default runs
nothing. Composition, not a default that overriders chain with super, keeps
the split from coming back for an application that overrides databaseInit
for something else.

The examples, the Tour and the docs drop the duplicated hook and stop
saying both halves are needed.

Closes #315

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The page warned that the schema alone is on the path and that objects in
public are not, but never said what to do about it. One sentence now
points at databaseInit, which runs after the schema step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rcardin rcardin self-assigned this Oct 22, 2026
@rcardin
rcardin merged commit d4dba87 into dev Oct 22, 2026
4 checks passed
@rcardin
rcardin deleted the issue-315 branch October 22, 2026 19:24
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