Skip to content

Commit d7410fb

Browse files
authored
fix(docker): create the organizations table core's redirect depends on (#38)
* fix(docker): create the organizations table core's redirect depends on 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 * fix(docker): stop the `prepare` script from breaking the production install `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.
1 parent a3b2415 commit d7410fb

9 files changed

Lines changed: 243 additions & 15 deletions

File tree

‎DOCKER.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,24 @@ docker run --rm -v linkforty_postgres_data:/data -v $(pwd):/backup alpine \
149149

150150
### Health Checks
151151

152-
The LinkForty container includes a built-in health check:
152+
The server exposes two endpoints:
153+
154+
| Endpoint | Checks | Use for |
155+
|-----------------|-------------------------------------|--------------------------------------------|
156+
| `/health` | Process is up. Never touches the DB | Liveness probes — a DB blip won't restart you |
157+
| `/health/ready` | Database reachable (+ Redis status) | Readiness probes, load balancer draining |
158+
159+
`/health/ready` returns `503` when the database is unreachable:
160+
161+
```bash
162+
curl -s localhost:3000/health/ready
163+
# {"status":"ok","checks":{"database":"ok","redis":"ok"}}
164+
```
165+
166+
Redis is an optional cache with database fallback, so a Redis failure is reported
167+
in `checks` but does not make the instance unready.
168+
169+
The container's built-in `HEALTHCHECK` targets `/health/ready`:
153170

154171
```bash
155172
# Check container health

‎Dockerfile‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ WORKDIR /app
66
# Copy package files
77
COPY package*.json ./
88

9-
# Install dependencies (including devDependencies for build)
10-
RUN npm ci
9+
# Install dependencies (including devDependencies for build).
10+
# --ignore-scripts skips the `prepare` script, which would otherwise run tsc here,
11+
# before the source is even copied. The real build is the explicit step below.
12+
RUN npm ci --ignore-scripts
1113

1214
# Copy source files
1315
COPY . .
@@ -30,13 +32,17 @@ WORKDIR /app
3032
# Copy package files
3133
COPY package*.json ./
3234

33-
# Install production dependencies only
34-
RUN npm ci --only=production && \
35+
# Install production dependencies only.
36+
# --ignore-scripts is required: package.json has a `prepare` script (npm run build)
37+
# that npm runs automatically on install, and it needs tsc from devDependencies —
38+
# which this stage deliberately omits. Without it the build dies with exit 127.
39+
RUN npm ci --omit=dev --ignore-scripts && \
3540
npm cache clean --force
3641

3742
# Copy built files from builder
43+
# NOTE: there is no migrations/ directory — the schema is created by
44+
# initializeDatabase() in dist/lib/database.js, which dist/scripts/migrate.js runs.
3845
COPY --from=builder /app/dist ./dist
39-
COPY --from=builder /app/migrations ./migrations
4046

4147
# Copy example server file
4248
COPY examples/basic-server.ts ./
@@ -55,7 +61,7 @@ EXPOSE 3000
5561

5662
# Health check
5763
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
58-
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
64+
CMD node -e "require('http').get('http://localhost:3000/health/ready', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => process.exit(1))"
5965

6066
# Use dumb-init to handle signals properly
6167
ENTRYPOINT ["dumb-init", "--"]

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,13 @@ GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redi
271271
GET /api/sdk/v1/health # Health check
272272
```
273273
274+
### Health
275+
276+
```bash
277+
GET /health # Liveness — process is up (no DB access)
278+
GET /health/ready # Readiness — 503 if the database is unreachable
279+
```
280+
274281
### Debug & Testing
275282
276283
```bash

‎docker-compose.yml‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,13 @@ services:
7171
- "${LINKFORTY_PORT:-3000}:3000"
7272
restart: unless-stopped
7373

74-
#Health check (optional, uncomment if needed)
75-
#healthcheck:
76-
#test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
77-
# interval: 30s
78-
# timeout: 10s
79-
# retries: 3
80-
# start_period: 40s
74+
#/health is liveness (process up); /health/ready also checks the database.
75+
healthcheck:
76+
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health/ready"]
77+
interval: 30s
78+
timeout: 10s
79+
retries: 3
80+
start_period: 40s
8181

8282
volumes:
8383
postgres_data:

‎src/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { webhookRoutes } from './routes/webhooks.js';
1010
import{templateRoutes}from'./routes/templates.js';
1111
import{qrRoutes}from'./routes/qr.js';
1212
import{wellKnownRoutes}from'./routes/well-known.js';
13+
import{healthRoutes}from'./routes/health.js';
1314

1415
/**
1516
* Configuration options for creating a LinkForty server instance.
@@ -58,6 +59,7 @@ export async function createServer(options: ServerOptions = {}) {
5859
awaitinitializeDatabase(options.database);
5960

6061
// Routes
62+
awaitfastify.register(healthRoutes);
6163
awaitfastify.register(wellKnownRoutes);
6264
awaitfastify.register(redirectRoutes);
6365
awaitfastify.register(linkRoutes);
@@ -78,4 +80,4 @@ export * from './lib/fingerprint.js';
7880
export*from'./lib/webhook.js';
7981
export*from'./lib/event-emitter.js';
8082
export*from'./types/index.js';
81-
export{redirectRoutes,linkRoutes,analyticsRoutes,sdkRoutes,webhookRoutes,templateRoutes,qrRoutes,previewRoutes,debugRoutes,wellKnownRoutes}from'./routes/index.js';
83+
export{redirectRoutes,linkRoutes,analyticsRoutes,sdkRoutes,webhookRoutes,templateRoutes,qrRoutes,previewRoutes,debugRoutes,wellKnownRoutes,healthRoutes}from'./routes/index.js';

‎src/lib/database.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,29 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
5151
constclient=awaitconnectWithRetry();
5252

5353
try{
54+
// Organizations table (must be created before links, which references it).
55+
//
56+
// The redirect path LEFT JOINs this table to read `settings.appConfig`, which
57+
// is the last link in the iOS/Android/web URL fallback chain (link → template
58+
// → organization). Core therefore *depends* on the table existing even though
59+
// richer deployments own the real one: without it every redirect fails with
60+
// `relation "organizations" does not exist` (issue #35).
61+
//
62+
// Deliberately minimal — id and settings are all the redirect reads, plus
63+
// suspended_at for the owner-restriction gate. CREATE TABLE IF NOT EXISTS is a
64+
// no-op against a deployment that already ships a fuller organizations table,
65+
// so this cannot clobber one.
66+
awaitclient.query(`
67+
CREATE TABLE IF NOT EXISTS organizations (
68+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
69+
name VARCHAR(255),
70+
settings JSONB DEFAULT '{}',
71+
suspended_at TIMESTAMP,
72+
created_at TIMESTAMP DEFAULT NOW(),
73+
updated_at TIMESTAMP DEFAULT NOW()
74+
)
75+
`);
76+
5477
// Link templates table (must be created before links, which references it)
5578
awaitclient.query(`
5679
CREATE TABLE IF NOT EXISTS link_templates (
@@ -190,6 +213,24 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
190213
)
191214
`);
192215

216+
// Add organization_id column to links table.
217+
//
218+
// The redirect join is `ON l.organization_id = o.id`, so the column is as
219+
// load-bearing as the table itself. Nullable and unset by default: a core
220+
// deployment that never populates it simply gets NULL org_settings and the
221+
// fallback chain stops at the template level, exactly as before.
222+
awaitclient.query(`
223+
DO $$
224+
BEGIN
225+
IF NOT EXISTS (
226+
SELECT 1 FROM information_schema.columns
227+
WHERE table_name='links' AND column_name='organization_id'
228+
) THEN
229+
ALTER TABLE links ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL;
230+
END IF;
231+
END $$;
232+
`);
233+
193234
// Add template_id column to links table
194235
awaitclient.query(`
195236
DO $$
@@ -496,6 +537,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
496537
awaitclient.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_link_templates_slug ON link_templates(slug)');
497538
awaitclient.query('CREATE INDEX IF NOT EXISTS idx_link_templates_user_id ON link_templates(user_id)');
498539
awaitclient.query('CREATE INDEX IF NOT EXISTS idx_links_template_id ON links(template_id)');
540+
awaitclient.query('CREATE INDEX IF NOT EXISTS idx_links_organization_id ON links(organization_id)');
499541

500542
// Indexes for webhooks
501543
awaitclient.query('CREATE INDEX IF NOT EXISTS idx_webhooks_user_id ON webhooks(user_id)');

‎src/routes/health.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Route-level tests for the health endpoints.
3+
*
4+
* The regression these lock down (issue #35): /health used to have no route at
5+
* all, so it fell through to the catch-all redirect `/:shortCode` and was
6+
* answered as a short-code lookup. The last test registers the real redirect
7+
* plugin alongside health to prove the static path wins.
8+
*/
9+
import{describe,it,expect,vi,beforeEach,afterEach}from'vitest';
10+
importFastify,{typeFastifyInstance}from'fastify';
11+
import{healthRoutes}from'./health.js';
12+
import{redirectRoutes}from'./redirect.js';
13+
14+
constquery=vi.fn();
15+
vi.mock('../lib/database.js',()=>({
16+
db: {
17+
query: (...args: unknown[])=>query(...args),
18+
},
19+
}));
20+
21+
letapp: FastifyInstance;
22+
23+
beforeEach(async()=>{
24+
query.mockReset();
25+
query.mockResolvedValue({rows: [{'?column?': 1}],rowCount: 1});
26+
app=Fastify();
27+
awaitapp.register(healthRoutes);
28+
awaitapp.ready();
29+
});
30+
31+
afterEach(async()=>{
32+
awaitapp.close();
33+
});
34+
35+
describe('GET /health',()=>{
36+
it('reports the process is up',async()=>{
37+
constres=awaitapp.inject({method: 'GET',url: '/health'});
38+
expect(res.statusCode).toBe(200);
39+
expect(res.json()).toMatchObject({status: 'ok'});
40+
});
41+
42+
it('never touches the database, so a database outage cannot fail liveness',async()=>{
43+
query.mockRejectedValue(newError('connection refused'));
44+
constres=awaitapp.inject({method: 'GET',url: '/health'});
45+
expect(res.statusCode).toBe(200);
46+
expect(query).not.toHaveBeenCalled();
47+
});
48+
});
49+
50+
describe('GET /health/ready',()=>{
51+
it('is ready when the database answers',async()=>{
52+
constres=awaitapp.inject({method: 'GET',url: '/health/ready'});
53+
expect(res.statusCode).toBe(200);
54+
expect(res.json()).toEqual({status: 'ok',checks: {database: 'ok'}});
55+
});
56+
57+
it('is 503 when the database is unreachable',async()=>{
58+
query.mockRejectedValue(newError('connection refused'));
59+
constres=awaitapp.inject({method: 'GET',url: '/health/ready'});
60+
expect(res.statusCode).toBe(503);
61+
expect(res.json()).toEqual({status: 'error',checks: {database: 'error'}});
62+
});
63+
64+
it('reports a failing Redis as degraded, not unready',async()=>{
65+
constwithRedis=Fastify();
66+
withRedis.decorate('redis',{ping: async()=>{thrownewError('down');}}asnever);
67+
awaitwithRedis.register(healthRoutes);
68+
awaitwithRedis.ready();
69+
70+
constres=awaitwithRedis.inject({method: 'GET',url: '/health/ready'});
71+
expect(res.statusCode).toBe(200);
72+
expect(res.json()).toEqual({status: 'ok',checks: {database: 'ok',redis: 'error'}});
73+
awaitwithRedis.close();
74+
});
75+
});
76+
77+
describe('regression: /health is not swallowed by the redirect route',()=>{
78+
it('answers health, not a short-code lookup, when both plugins are registered',async()=>{
79+
constcombined=Fastify();
80+
// Redirect first — the static route must win on specificity, not order.
81+
awaitcombined.register(redirectRoutes);
82+
awaitcombined.register(healthRoutes);
83+
awaitcombined.ready();
84+
85+
constres=awaitcombined.inject({method: 'GET',url: '/health'});
86+
expect(res.statusCode).toBe(200);
87+
expect(res.json()).toMatchObject({status: 'ok'});
88+
// The redirect handler would have run a links lookup; health must not.
89+
constlinkLookups=query.mock.calls.filter(([sql])=>/FROMlinks/i.test(String(sql)));
90+
expect(linkLookups).toHaveLength(0);
91+
92+
awaitcombined.close();
93+
});
94+
});

‎src/routes/health.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import{FastifyInstance}from'fastify';
2+
import{db}from'../lib/database.js';
3+
4+
/**
5+
* Health endpoints.
6+
*
7+
* Without these, `GET /health` fell through to the catch-all redirect route
8+
* `/:shortCode` and was answered as a short-code lookup — a 404 at best, and on
9+
* a self-hosted install a 500, which made the Docker HEALTHCHECK (which targets
10+
* /health) permanently unhealthy and buried the real error (issue #35).
11+
*
12+
* Two levels, following the usual liveness/readiness split:
13+
*
14+
* /health — liveness. The process is up and serving. Never touches the
15+
* database, so a database blip cannot get the container killed
16+
* by an orchestrator that restarts on a failing probe.
17+
* /health/ready — readiness. Confirms the database answers, and reports Redis
18+
* when it is configured. 503 when the database is unreachable,
19+
* so a load balancer can drain the instance.
20+
*
21+
* Both are static paths, which Fastify's router always prefers over the
22+
* parametric `/:shortCode`, so they cannot be shadowed by a short link — and by
23+
* the same token `health` is no longer usable as a short code.
24+
*/
25+
exportasyncfunctionhealthRoutes(fastify: FastifyInstance){
26+
fastify.get('/health',async()=>({
27+
status: 'ok',
28+
uptime: Math.floor(process.uptime()),
29+
}));
30+
31+
fastify.get('/health/ready',async(_request,reply)=>{
32+
constchecks: Record<string,string>={};
33+
34+
try{
35+
awaitdb.query('SELECT 1');
36+
checks.database='ok';
37+
}catch(error){
38+
fastify.log.error(`Health: database check failed: ${error}`);
39+
checks.database='error';
40+
}
41+
42+
if(fastify.redis){
43+
try{
44+
awaitfastify.redis.ping();
45+
checks.redis='ok';
46+
}catch{
47+
// Redis is an optional cache with database fallback, so a failure here
48+
// is degraded, not unready — it must not flip the overall status.
49+
checks.redis='error';
50+
}
51+
}
52+
53+
constready=checks.database==='ok';
54+
returnreply.status(ready ? 200 : 503).send({
55+
status: ready ? 'ok' : 'error',
56+
checks,
57+
});
58+
});
59+
}

‎src/routes/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { templateRoutes } from './templates.js';
88
export{previewRoutes}from'./preview.js';
99
export{debugRoutes}from'./debug.js';
1010
export{wellKnownRoutes}from'./well-known.js';
11+
export{healthRoutes}from'./health.js';

0 commit comments

Comments
 (0)