Skip to content

fix(docker): create the organizations table core's redirect depends on - #38

Merged
onamfc merged 2 commits into
mainfrom
brandon/fix-35-missing-organizations-table
Aug 11, 2026
Merged

fix(docker): create the organizations table core's redirect depends on#38
onamfc merged 2 commits into
mainfrom
brandon/fix-35-missing-organizations-table

Conversation

@onamfc

@onamfc onamfc commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Every redirect on a stock self-hosted install returns HTTP 500 with relation "organizations" does not exist. The deployment is effectively dead on arrival — links don't resolve, and the health check reports the same error, which is what made this hard to diagnose.

Investigating turned up four independent defects, all on main. Each is individually sufficient to break a Docker deployment.

1. Core queries an organizations table it never creates

The redirect lookup joins it to read settings.appConfig — the last link in the iOS/Android/web URL fallback chain:

LEFT JOIN organizations o ON l.organization_id = o.id

But initializeDatabase() creates neither the table nor the links.organization_id column. Core depends on a table it doesn't ship, so Postgres fails every redirect with 42P01.

Fix: create a minimal organizations table (id, name, settings, suspended_at) and add links.organization_id, both idempotent. CREATE TABLE IF NOT EXISTS is a no-op against deployments that already own a fuller table, so Cloud's schema is preserved untouched — verified below.

2. There was no /health route at all

GET /health had no handler, so it fell through to the catch-all redirect /:shortCode and was answered as a short-code lookup — which is why checking health surfaced the organizations error. DOCKER.md has documented this endpoint all along, and the Dockerfile HEALTHCHECK targets it, so containers could never report healthy.

Fix: add both halves of the usual split.

Endpoint Checks Purpose
/health Process is up. Never touches the DB Liveness — a database blip can't get the container killed
/health/ready Database reachable (+ Redis status) Readiness — 503 so a load balancer can drain

Redis is an optional cache with database fallback, so a Redis failure is reported as degraded rather than unready. Static paths beat the parametric redirect route in Fastify's router, so neither can be shadowed by a short link.

3. The Dockerfile copied a directory that doesn't exist

COPY --from=builder /app/migrations ./migrations

There is no migrations/ directory in this repo and never has been, so building from source died with "/app/migrations": not found. Only the published-image path worked, which is likely why it went unnoticed.

Fix: drop the layer. The schema is created by initializeDatabase(), which dist/scripts/migrate.js already runs.

4. The prepare script broke the production install

Found only by actually building the image. package.json defines prepare: npm run build, which npm runs automatically on install — but the production stage omits devDependencies, so tsc is gone:

> @linkforty/core@1.20.0 prepare
> npm run build
sh: tsc: not found
npm error code 127

Fix: --ignore-scripts on both installs, and the deprecated --only=production switched to --omit=dev. The builder stage didn't need the implicit build either — it ran tsc before the source was even copied.

Verification

Reproduced first, against real PostgreSQL 15 — running the actual initializeDatabase() on main and issuing the exact redirect query returns the reported error verbatim:

[BEFORE] REDIRECT QUERY: FAILED code=42P01 message=relation "organizations" does not exist
[BEFORE] organizations table: MISSING
[BEFORE] links.organization_id: MISSING

With the fix, four schema paths:

Scenario Result
Fresh install Redirect query OK; table and column present
Upgrade of an already-broken database OK — this is the path existing users are on
Cloud-style pre-existing organizations Extra columns (slug, stripe_customer_id, plan) all preserved, and suspended_at correctly not added
Re-run on same database Idempotent

That third row is the one that matters for #37: a richer table is left completely alone, so the organizations.suspended_at probe still correctly reports owner restriction as unsupported there.

Then end-to-end in the real container against Postgres and Redis:

$ docker inspect -f '{{.State.Health.Status}}' lf35app
healthy

$ curl localhost:3399/health
{"status":"ok","uptime":129}

$ curl localhost:3399/health/ready
{"status":"ok","checks":{"database":"ok","redis":"ok"}}

$ curl -o /dev/null -w "%{http_code} %{redirect_url}" localhost:3399/e2etest
302 https://example.com/landing        # this was the 500

$ curl -o /dev/null -w "%{http_code}" localhost:3399/nosuchcode
404                                    # unknown codes still 404
  • npm test — 145 passing, including 6 new route-level tests for the health endpoints. One is a direct regression test for defect 2: it registers the real redirect plugin first, then health, and asserts /health answers health rather than running a links lookup.
  • npm run build clean.
  • docker build succeeds (it did not before).

Notes for reviewers

  • Why create the table rather than guard the join. Core's redirect genuinely reads org_settings.appConfig for the URL fallback chain, so the concept is already load-bearing here — it was just never shipped. Creating the minimal version makes the package self-consistent, and idempotent DDL means richer deployments are unaffected. The alternative (probing for the table like feat(redirect): link safety states and owner-restriction gate #37 probes for the column) leaves core permanently unable to use a feature its own redirect path references.
  • Relationship to feat(redirect): link safety states and owner-restriction gate #37. That PR probes for organizations.suspended_at before selecting it, but the LEFT JOIN organizations stays unconditional — it guards the column, not the table. Rebasing feat(redirect): link safety states and owner-restriction gate #37 onto this makes its "works byte-for-byte as before" comment actually true.
  • health is now a reserved short code, along with health/ready.

Fixes #35

onamfc added 2 commits August 10, 2026 18:19
Every redirect on a self-hosted install failed with
`relation "organizations" does not exist` (42P01), taking the whole
deployment down. Three separate defects combined to produce it:

1. The redirect lookup LEFT JOINs `organizations` to read `settings.appConfig`
   (the last link in the ios/android/web URL fallback chain) and joins on
   `links.organization_id` — but `initializeDatabase()` created neither the
   table nor the column. Core depended on a table it never shipped.

2. There was no `/health` route. `GET /health` fell through to the catch-all
   redirect `/:shortCode` and was answered as a short-code lookup, so the
   documented health check surfaced the same 500 and the container's
   HEALTHCHECK could never pass. This is why the error looked like it came
   from the health endpoint.

3. The Dockerfile copied a `migrations/` directory that does not exist in the
   repo, so `docker build` from source failed outright at that layer.

Fixes:

- Create a minimal `organizations` table (id, name, settings, suspended_at)
  and add `links.organization_id`, both idempotent. CREATE TABLE IF NOT EXISTS
  is a no-op against deployments that already ship a fuller organizations
  table, so a richer schema is preserved untouched.
- Add `/health` (liveness, no DB access) and `/health/ready` (503 when the
  database is unreachable; Redis reported as degraded, not unready). Static
  paths beat the parametric redirect route in Fastify's router, so they cannot
  be shadowed by a short link.
- Drop the bogus `COPY migrations` layer and point HEALTHCHECK at
  `/health/ready`, with an error handler so a dead server exits non-zero.

Verified against PostgreSQL 15: reproduced 42P01 on main, then confirmed the
fix on a fresh install, on an upgrade of an already-broken database, and
against a Cloud-style pre-existing organizations table (columns preserved,
suspended_at correctly absent).

Fixes #35
…nstall

`docker build` failed at the production stage with exit code 127:

    > @linkforty/core@1.20.0 prepare
    > npm run build
    sh: tsc: not found

package.json defines `prepare: npm run build`, which npm runs automatically on
install. The production stage installs with devDependencies omitted, so tsc is
absent and the implicit build dies — taking the whole image with it.

Adds --ignore-scripts to both installs and switches the deprecated
--only=production to --omit=dev. The builder stage does not need the implicit
build either: it ran tsc before the source was even copied, and the real build
is the explicit `npm run build` step that follows.

Verified: image builds, and the container comes up healthy against Postgres and
Redis — link creation, a 302 redirect through a short code, a 404 on an unknown
code, and both health endpoints all behave.
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.23810% with 47 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/lib/database.ts 0.00% 42 Missing ⚠️
src/index.ts 0.00% 3 Missing ⚠️
src/routes/health.ts 98.30% 1 Missing ⚠️
src/routes/index.ts 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@onamfc
onamfc force-pushed the brandon/fix-35-missing-organizations-table branch from 951b27f to 13f6cec Compare August 11, 2026 19:03
onamfc added a commit that referenced this pull request Aug 11, 2026
The `cla` check has been failing on every open PR (#34, #36, #37, #38):

    ##[error]Could not retrieve repository contents. Status: 404

cla-assistant/github-action defaults `branch` to 'master'. This repo's default
branch is `main` and no `master` ref exists, so the action's first call —

    octokit.repos.getContent({ path: pathToSignatures, ref: getBranch() })

— asks for a signature file on a ref that isn't there and 404s before it can do
anything. Nothing was ever broken about the PRs themselves.

Three fixes:

- branch: 'main' so the ref resolves. `path-to-signatures` is also pinned
  explicitly rather than relying on the action's default, since the pair only
  makes sense read together.
- contents: write. The action commits the signature file when someone signs;
  with contents:read it would 403 on the write immediately after the read
  started succeeding, trading one failure for another.
- Drop `path-to-cla-assistants`, which is not an input this action accepts and
  was logged as `Unexpected input(s)` on every run.

Note that `license/cla` (the cla-assistant.io status check) has been passing
throughout — only the GitHub Action was failing, so signature enforcement was
never actually bypassed.

Signatures will land on `main` as `signatures/cla.json`. That triggers CI and
Release on each new signer; semantic-release no-ops on a non-conventional
commit, and new signers are rare. If that noise ever matters, the alternative
is a dedicated `cla-signatures` branch — which has to exist first, or it
reproduces this exact bug.
onamfc added a commit that referenced this pull request Aug 11, 2026
The `cla` check has been failing on every open PR (#34, #36, #37, #38):

    ##[error]Could not retrieve repository contents. Status: 404

cla-assistant/github-action defaults `branch` to 'master'. This repo's default
branch is `main` and no `master` ref exists, so the action's first call —

    octokit.repos.getContent({ path: pathToSignatures, ref: getBranch() })

— asks for a signature file on a ref that isn't there and 404s before it can do
anything. Nothing was ever broken about the PRs themselves.

Three fixes:

- branch: 'main' so the ref resolves. `path-to-signatures` is also pinned
  explicitly rather than relying on the action's default, since the pair only
  makes sense read together.
- contents: write. The action commits the signature file when someone signs;
  with contents:read it would 403 on the write immediately after the read
  started succeeding, trading one failure for another.
- Drop `path-to-cla-assistants`, which is not an input this action accepts and
  was logged as `Unexpected input(s)` on every run.

Note that `license/cla` (the cla-assistant.io status check) has been passing
throughout — only the GitHub Action was failing, so signature enforcement was
never actually bypassed.

Signatures will land on `main` as `signatures/cla.json`. That triggers CI and
Release on each new signer; semantic-release no-ops on a non-conventional
commit, and new signers are rare. If that noise ever matters, the alternative
is a dedicated `cla-signatures` branch — which has to exist first, or it
reproduces this exact bug.
@onamfc
onamfc merged commit d7410fb into main Aug 11, 2026
12 of 13 checks passed
github-actions Bot pushed a commit that referenced this pull request Aug 11, 2026
## <small>1.20.1 (2026-08-11)</small>

* fix(docker): create the organizations table core's redirect depends on (#38) ([d7410fb](d7410fb)), closes [#38](#38) [#35](#35)
* ci: point the CLA action at a branch that exists (#39) ([a3b2415](a3b2415)), closes [#39](#39) [#34](#34) [#36](#36) [#37](#37) [#38](#38)
* chore: remove unused backfill scripts (bot-flags, attribution) (#33) ([5b8b047](5b8b047)), closes [#33](#33)
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.20.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@github-actions github-actions Bot locked and limited conversation to collaborators Aug 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: "relation 'organizations' does not exist" error when self-hosting with Docker Compose.

1 participant