Skip to content

Commit 633023e

Browse files
lane711claude
andcommitted
fix(self-host): entrypoint-based seed prevents concurrent SQLite corruption
Seeds the DB sequentially before the server starts so WAL write conflicts cannot corrupt the database. Rewrites seed-self-host.ts to use createSqliteDriver (auto-migrate + WAL) instead of mixing a raw better-sqlite3 connection with the driver — safe to run before first boot. Adds docker-entrypoint.sh to Dockerfile; updates E2E smoke spec to use page.request for cookie sharing and API-based auth verification. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c804741 commit 633023e

6 files changed

Lines changed: 56 additions & 47 deletions

File tree

Dockerfile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,15 @@ ENV SONICJS_STORAGE_PATH=/app/data/media
6767
ENV SONICJS_KV_PATH=/app/data/kv.json
6868
ENV PORT=3000
6969

70+
# Copy entrypoint script.
71+
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
72+
7073
# Run as non-root for security.
71-
RUN chown -R node:node /app
74+
RUN chown -R node:node /app && chmod +x /app/docker-entrypoint.sh
7275
USER node
7376

7477
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
7578
CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
7679

77-
# Run via Node with native ESM + TypeScript stripping (Node 22+).
78-
CMD ["node", "--experimental-strip-types", "my-sonicjs-app/src/self-host.ts"]
80+
# Entrypoint: seeds DB on first boot then starts server (sequential, no concurrent SQLite access).
81+
ENTRYPOINT ["/app/docker-entrypoint.sh"]

docker-entrypoint.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/bin/sh
2+
# SonicJS Docker entrypoint
3+
# On first boot (no DB file): runs seed which auto-migrates + creates admin.
4+
# On subsequent boots: skips seed (idempotent check inside seed script).
5+
# Sequential execution guarantees no concurrent SQLite writer conflict.
6+
set -e
7+
8+
DB_PATH="${SONICJS_DB_PATH:-/app/data/sonicjs.db}"
9+
10+
if [ ! -f "$DB_PATH" ]; then
11+
echo "[entrypoint] First boot — running migrations + seed..."
12+
(cd /app/my-sonicjs-app && node --experimental-strip-types src/seed-self-host.ts)
13+
fi
14+
15+
echo "[entrypoint] Starting SonicJS server..."
16+
exec node --experimental-strip-types /app/my-sonicjs-app/src/self-host.ts

my-sonicjs-app/src/seed-self-host.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33
*
44
* Creates an initial admin user when the database has no users yet.
55
* Safe to run multiple times — exits silently if an admin already exists.
6+
* Safe to run BEFORE the server starts — applies migrations automatically.
67
*
78
* Usage:
89
* node --experimental-strip-types src/seed-self-host.ts
9-
* # or via npm script:
10+
* # or via npm script (from my-sonicjs-app/):
1011
* npm run reset
1112
*
1213
* Configure via environment variables:
13-
* SONICJS_DB_PATH Path to SQLite file (default: ./data/sonicjs.db)
14-
* SONICJS_ADMIN_EMAIL Admin email (default: admin@sonicjs.com)
15-
* SONICJS_ADMIN_PASSWORD Admin password (default: sonicjs!)
14+
* SONICJS_DB_PATH Path to SQLite file (default: ./data/sonicjs.db)
15+
* SONICJS_ADMIN_EMAIL Admin email (default: admin@sonicjs.com)
16+
* SONICJS_ADMIN_PASSWORD Admin password (default: sonicjs!)
1617
*/
1718

18-
import Database from 'better-sqlite3'
1919
import { hashPassword } from '@better-auth/utils/password'
20-
import { existsSync } from 'node:fs'
21-
import { resolve } from 'node:path'
20+
import { resolve, dirname } from 'node:path'
21+
import { mkdirSync } from 'node:fs'
2222
import { createSqliteDriver } from '@sonicjs-cms/core/adapters'
2323
import { bootstrapDocumentTypes, RbacService } from '@sonicjs-cms/core'
2424

@@ -27,18 +27,17 @@ const ADMIN_EMAIL = process.env.SONICJS_ADMIN_EMAIL ?? 'admin@sonicjs.com'
2727
const ADMIN_PASSWORD = process.env.SONICJS_ADMIN_PASSWORD ?? 'sonicjs!'
2828

2929
async function seed() {
30-
if (!existsSync(DB_PATH)) {
31-
console.error(`[seed] Database not found at ${DB_PATH}`)
32-
console.error('[seed] Start the server first so it auto-migrates, then run this script.')
33-
process.exit(1)
34-
}
30+
// Ensure data directory exists before opening DB.
31+
mkdirSync(dirname(DB_PATH), { recursive: true })
3532

36-
const db = new Database(DB_PATH)
33+
// createSqliteDriver: opens DB, enables WAL, auto-migrates schema if absent.
34+
// Safe to run before the server — no concurrent connection needed.
35+
const driver = await createSqliteDriver({ dbPath: DB_PATH, autoMigrate: true })
3736

3837
try {
3938
// Idempotency check — skip if any user already exists.
40-
const existing = db.prepare('SELECT COUNT(*) as n FROM auth_user').get() as { n: number }
41-
if (existing.n > 0) {
39+
const existing = await driver.prepare('SELECT COUNT(*) as n FROM auth_user').first<{ n: number }>()
40+
if (existing && existing.n > 0) {
4241
console.log('[seed] Admin already exists — skipping.')
4342
return
4443
}
@@ -49,42 +48,36 @@ async function seed() {
4948

5049
const passwordHash = await hashPassword(ADMIN_PASSWORD)
5150

52-
db.prepare(`
51+
await driver.prepare(`
5352
INSERT INTO auth_user (
5453
id, email, name, first_name, last_name,
5554
role, is_super_admin, is_active,
5655
email_verified, created_at, updated_at
5756
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
58-
`).run(userId, ADMIN_EMAIL, 'Admin', 'Admin', 'User', 'admin', 1, 1, 0, now, now)
57+
`).bind(userId, ADMIN_EMAIL, 'Admin', 'Admin', 'User', 'admin', 1, 1, 0, now, now).run()
5958

60-
db.prepare(`
59+
await driver.prepare(`
6160
INSERT INTO auth_account (
6261
id, user_id, account_id, provider_id, password, created_at, updated_at
6362
) VALUES (?, ?, ?, ?, ?, ?, ?)
64-
`).run(accountId, userId, userId, 'credential', passwordHash, now, now)
65-
66-
db.close()
63+
`).bind(accountId, userId, userId, 'credential', passwordHash, now, now).run()
6764

6865
// Bootstrap RBAC so the admin user can access /admin (requireRbac('portal', 'access')).
69-
// Uses the D1-compatible SqliteDriver so RbacService writes to the same SQLite file.
70-
const driver = await createSqliteDriver({ dbPath: DB_PATH, autoMigrate: false })
7166
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7267
await bootstrapDocumentTypes(driver as any)
7368
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7469
const rbac = new RbacService(driver as any)
7570
await rbac.ensureSystemRbacSeed()
7671
await rbac.addUserRoleByName(userId, 'admin')
77-
driver.close()
7872

7973
console.log('[seed] Admin user created:')
8074
console.log(` Email: ${ADMIN_EMAIL}`)
8175
console.log(` Password: ${ADMIN_PASSWORD}`)
8276
if (ADMIN_PASSWORD === 'sonicjs!') {
8377
console.log(' ⚠ Change this password after first login!')
8478
}
85-
return
8679
} finally {
87-
if (db.open) db.close()
80+
driver.close()
8881
}
8982
}
9083

packages/core/src/db/migrations-bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* AUTO-GENERATED FILE - DO NOT EDIT
33
* Generated by: scripts/generate-migrations.ts
4-
* Generated at: 2026-07-02T01:18:29.586Z
4+
* Generated at: 2026-07-02T22:07:06.083Z
55
*
66
* This file contains all migration SQL bundled for use in Cloudflare Workers
77
* where filesystem access is not available at runtime.

packages/core/src/plugins/manifest-registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Plugin Registry - AUTO-GENERATED
33
*
44
* Generated by: packages/scripts/generate-plugin-registry.mjs
5-
* Generated at: 2026-07-02T01:18:18.271Z
5+
* Generated at: 2026-07-02T22:07:05.438Z
66
* Source: All manifest.json files in src/plugins/
77
*
88
* DO NOT EDIT MANUALLY - run the generator script instead.

tests/e2e/94-self-host-smoke.spec.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,21 @@ test.describe('Self-Host Smoke Tests', () => {
3434
expect(page.url()).toMatch(/login/)
3535
})
3636

37-
test('sign-in with seeded admin credentials', async ({ page }) => {
38-
await page.goto('/admin')
39-
await page.waitForURL(/login/)
40-
41-
// Fill credentials
42-
const emailField = page.locator('input[type="email"], input[name="email"]').first()
43-
const passwordField = page.locator('input[type="password"], input[name="password"]').first()
44-
await emailField.fill(SELF_HOST_EMAIL)
45-
await passwordField.fill(SELF_HOST_PASSWORD)
46-
47-
await page.locator('button[type="submit"], input[type="submit"]').first().click()
37+
test('sign-in and access admin dashboard', async ({ page }) => {
38+
// Use page.request so the session cookie is shared with the browser context.
39+
const signIn = await page.request.post('/auth/sign-in/email', {
40+
data: { email: SELF_HOST_EMAIL, password: SELF_HOST_PASSWORD },
41+
})
42+
expect(signIn.ok()).toBeTruthy()
4843

49-
// Should land on admin dashboard
50-
await page.waitForURL(/\/admin/, { timeout: 10_000 })
51-
expect(page.url()).toContain('/admin')
44+
// Navigate to admin — cookie is now in the page's cookie jar.
45+
await page.goto('/admin')
46+
await page.waitForLoadState('networkidle', { timeout: 10_000 })
5247

53-
// Should NOT still be on login
54-
expect(page.url()).not.toMatch(/login/)
48+
// Should be on an admin page (not stuck on login).
49+
const url = page.url()
50+
expect(url).toContain('/admin')
51+
expect(url).not.toContain('login')
5552
})
5653

5754
test('admin content route accessible after sign-in', async ({ page, request }) => {

0 commit comments

Comments
 (0)