Repository navigation
feat(self-host): local profile runs PostgreSQL (TASK-503 slice 1) #2806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
backend/__tests__/unit/deployment/localComposePostgres.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // TASK-503: chat messages become Postgres-only, so the one-command local | ||
| // install (./install.sh -> docker-compose.local.yml) must run Postgres. It ran | ||
| // MongoDB alone until this change, and removing the fallback would have broken | ||
| // every local self-host. These are presence checks on the files the installer | ||
| // actually reads; the boot itself is proven by running ./install.sh. | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const read = (name) => fs.readFileSync(path.join(__dirname, '../../../../', name), 'utf8'); | ||
| const compose = read('docker-compose.local.yml'); | ||
| const serviceBlock = (name) => { | ||
| const start = compose.search(new RegExp(`^ ${name}:\\s*$`, 'm')); | ||
| expect(start).toBeGreaterThanOrEqual(0); | ||
| const rest = compose.slice(start + 1); | ||
| const next = rest.search(/^ {2}[a-z][\w-]*:\s*$|^[a-z][\w-]*:\s*$/m); | ||
| return next === -1 ? rest : rest.slice(0, next); | ||
| }; | ||
|
|
||
| describe('local compose runs Postgres for chat', () => { | ||
| it('declares a Postgres service pinned to a major version, with a health check and a volume', () => { | ||
| const pg = serviceBlock('postgres'); | ||
| expect(pg).toMatch(/image: postgres:\d+(-alpine)?\s*$/m); | ||
| expect(pg).toMatch(/pg_isready/); | ||
| expect(pg).toMatch(/postgres-data:\/var\/lib\/postgresql\/data/); | ||
| expect(compose).toMatch(/^volumes:[\s\S]*^ {2}postgres-data:/m); | ||
| // Not published to the host: only the app ports are. | ||
| expect(pg).not.toMatch(/ports:/); | ||
| }); | ||
|
|
||
| it('starts the backend after Postgres is healthy and hands it the PG_* settings', () => { | ||
| const backend = serviceBlock('backend'); | ||
| expect(backend).toMatch(/postgres:\s*\n\s*condition: service_healthy/); | ||
| ['PG_HOST=postgres', 'PG_PORT=5432', 'PG_USER=', 'PG_PASSWORD=${PG_PASSWORD:?', 'PG_DATABASE=', 'PG_SSL_ENABLED=false'] | ||
| .forEach((setting) => expect(backend).toContain(setting)); | ||
| }); | ||
|
|
||
| it.each(['install.sh', 'scripts/demo-bootstrap.sh'])('%s writes a PG_PASSWORD into .env when one is missing', (name) => { | ||
| const script = read(name); | ||
| expect(script).toMatch(/grep -Eq '\^PG_PASSWORD=\.\+\$'/); | ||
| expect(script).toMatch(/PG_PASSWORD=%s/); | ||
| }); | ||
| }); |
61 changes: 61 additions & 0 deletions
61
backend/__tests__/unit/scripts/backfillLocalMessagesToPg.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| jest.mock('../../../services/pgMessageReconcileService', () => ({ copyOne: jest.fn(), ensureLegacyIdIndex: jest.fn() })); | ||
|
|
||
| const { backfillLocalMessages } = require('../../../scripts/backfill-local-messages-to-pg'); | ||
|
|
||
| const doc = (id) => ({ _id: { toString: () => id }, podId: 'p', userId: 'u', content: id, createdAt: new Date('2026-01-01') }); | ||
|
|
||
| const fakeDeps = ({ pending = ['a1', 'a2', 'a3'], overlap = 0, copy } = {}) => { | ||
| const copied = []; | ||
| return { | ||
| copied, | ||
| countPending: jest.fn(async () => pending.length), | ||
| newestPendingAt: jest.fn(async () => new Date('2026-01-01')), | ||
| countOverlap: jest.fn(async () => overlap), | ||
| nextBatch: jest.fn(async (afterId, limit) => { | ||
| const start = afterId ? pending.indexOf(afterId) + 1 : 0; | ||
| return pending.slice(start, start + limit).map(doc); | ||
| }), | ||
| copy: jest.fn(copy || (async (d) => { copied.push(d._id.toString()); return 'moved'; })), | ||
| }; | ||
| }; | ||
|
|
||
| describe('backfillLocalMessages', () => { | ||
| it('a dry run reports the pending count and writes nothing', async () => { | ||
| const deps = fakeDeps(); | ||
| const report = await backfillLocalMessages(deps); | ||
| expect(report).toMatchObject({ dryRun: true, pending: 3, refused: false, moved: 0 }); | ||
| expect(deps.copy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('--apply copies every pending message across batches', async () => { | ||
| const deps = fakeDeps({ pending: ['a1', 'a2', 'a3', 'a4', 'a5'] }); | ||
| const report = await backfillLocalMessages(deps, { apply: true, batchSize: 2 }); | ||
| expect(deps.copied).toEqual(['a1', 'a2', 'a3', 'a4', 'a5']); | ||
| expect(report).toMatchObject({ moved: 5, alreadyPresent: 0, failed: 0 }); | ||
| }); | ||
|
|
||
| it('counts rows PostgreSQL already had, so a rerun inserts nothing new', async () => { | ||
| const deps = fakeDeps({ copy: async () => 'present' }); | ||
| const report = await backfillLocalMessages(deps, { apply: true }); | ||
| expect(report).toMatchObject({ moved: 0, alreadyPresent: 3 }); | ||
| }); | ||
|
|
||
| it('refuses when PostgreSQL holds older messages that did not come from this MongoDB', async () => { | ||
| const deps = fakeDeps({ overlap: 12 }); | ||
| const report = await backfillLocalMessages(deps, { apply: true }); | ||
| expect(report).toMatchObject({ refused: true, overlap: 12, moved: 0 }); | ||
| expect(deps.copy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('--force copies despite the overlap', async () => { | ||
| const deps = fakeDeps({ overlap: 12 }); | ||
| const report = await backfillLocalMessages(deps, { apply: true, force: true }); | ||
| expect(report).toMatchObject({ refused: false, moved: 3 }); | ||
| }); | ||
|
|
||
| it('a failed row is counted and the run moves past it', async () => { | ||
| const deps = fakeDeps({ copy: async (d) => { if (d._id.toString() === 'a2') throw new Error('bad'); return 'moved'; } }); | ||
| const report = await backfillLocalMessages(deps, { apply: true, batchSize: 1 }); | ||
| expect(report).toMatchObject({ moved: 2, failed: 1 }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| /** | ||
| * backfill-local-messages-to-pg — one-shot, for a self-hosted install that ran | ||
| * chat on MongoDB only and has just gained PostgreSQL (TASK-503). | ||
| * | ||
| * The local profile (docker-compose.local.yml) used to run without Postgres, | ||
| * so every chat message lived in Mongo. Once Postgres is healthy, chat reads | ||
| * Postgres only and never merges the two stores, so that history disappears | ||
| * from the UI. The background reconciler (pgMessageReconcileService) does not | ||
| * help: it moves only rows flagged `pgFallback`, written while PG was down. | ||
| * | ||
| * This copies every not-yet-reconciled Mongo message through the reconciler's | ||
| * own `copyOne`, so it inherits its guarantees: the PG insert is ON CONFLICT | ||
| * on `legacy_mongo_id` and the Mongo row is stamped `reconciledAt` afterwards, | ||
| * so a rerun or a crash mid-run inserts nothing twice. | ||
| * | ||
| * REFUSES on overlapping history. A deployment whose PG already holds messages | ||
| * that never came from this Mongo (no `legacy_mongo_id`) and that predate the | ||
| * newest Mongo row may have the same messages in both stores, copied by an | ||
| * older migration that did not record the Mongo id. Copying again would | ||
| * duplicate them. That is not the local-upgrade case, so it stops and says why; | ||
| * `--force` is for an operator who has checked. | ||
| * | ||
| * Dry run by default; `--apply` writes. Replies keep their text but not their | ||
| * reply edge, same as the reconciler. | ||
| * | ||
| * npm run backfill:local-messages # report only | ||
| * npm run backfill:local-messages -- --apply | ||
| */ | ||
| /* eslint-disable no-console, no-await-in-loop */ | ||
|
|
||
| import mongoose from 'mongoose'; | ||
| import { copyOne, ensureLegacyIdIndex } from '../services/pgMessageReconcileService'; | ||
|
|
||
| type CopyResult = 'moved' | 'present'; | ||
|
|
||
| export interface PendingDoc { | ||
| _id: { toString(): string }; | ||
| podId: { toString(): string }; | ||
| userId: { toString(): string }; | ||
| content: string; | ||
| createdAt: Date; | ||
| } | ||
|
|
||
| export interface LocalBackfillDeps { | ||
| countPending: () => Promise<number>; | ||
| newestPendingAt: () => Promise<Date | null>; | ||
| /** PG rows with no legacy_mongo_id written at or before `at`. */ | ||
| countOverlap: (at: Date) => Promise<number>; | ||
| /** The next pending docs after `afterId`, oldest id first. */ | ||
| nextBatch: (afterId: string | null, limit: number) => Promise<PendingDoc[]>; | ||
| copy: (doc: PendingDoc) => Promise<CopyResult>; | ||
| } | ||
|
|
||
| export interface LocalBackfillReport { | ||
| dryRun: boolean; | ||
| pending: number; | ||
| overlap: number; | ||
| refused: boolean; | ||
| moved: number; | ||
| alreadyPresent: number; | ||
| failed: number; | ||
| } | ||
|
|
||
| export async function backfillLocalMessages( | ||
| deps: LocalBackfillDeps, | ||
| { apply = false, force = false, batchSize = 500 }: { apply?: boolean; force?: boolean; batchSize?: number } = {}, | ||
| ): Promise<LocalBackfillReport> { | ||
| const pending = await deps.countPending(); | ||
| const newest = pending ? await deps.newestPendingAt() : null; | ||
| const overlap = newest ? await deps.countOverlap(newest) : 0; | ||
| const report: LocalBackfillReport = { | ||
| dryRun: !apply, pending, overlap, refused: overlap > 0 && !force, moved: 0, alreadyPresent: 0, failed: 0, | ||
| }; | ||
| if (!apply || report.refused || !pending) return report; | ||
|
|
||
| let afterId: string | null = null; | ||
| for (;;) { | ||
| const batch = await deps.nextBatch(afterId, batchSize); | ||
| if (!batch.length) break; | ||
| for (const doc of batch) { | ||
| try { | ||
| if (await deps.copy(doc) === 'moved') report.moved += 1; | ||
| else report.alreadyPresent += 1; | ||
| } catch (err) { | ||
| report.failed += 1; | ||
| console.warn('[backfill-local-messages] row not copied:', doc._id.toString(), (err as Error).message); | ||
| } | ||
| } | ||
| afterId = batch[batch.length - 1]._id.toString(); | ||
| } | ||
| return report; | ||
| } | ||
|
|
||
| interface PgPool { | ||
| query: (sql: string, values?: unknown[]) => Promise<{ rows: Array<Record<string, unknown>> }>; | ||
| end: () => Promise<void>; | ||
| } | ||
|
|
||
| const productionDeps = (pool: PgPool): LocalBackfillDeps => { | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require | ||
| const Message = require('../models/Message'); | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require | ||
| const { syncPodFromMongo } = require('../services/pgPodSyncService'); | ||
| const unreconciled = { reconciledAt: { $exists: false } }; | ||
| return { | ||
| countPending: () => Message.countDocuments(unreconciled), | ||
| newestPendingAt: async () => { | ||
| const doc = await Message.findOne(unreconciled).sort({ createdAt: -1 }).select('createdAt').lean(); | ||
| return doc?.createdAt || null; | ||
| }, | ||
| countOverlap: async (at) => { | ||
| const { rows } = await pool.query( | ||
| 'SELECT count(*)::int AS n FROM messages WHERE legacy_mongo_id IS NULL AND created_at <= $1', | ||
| [at], | ||
| ); | ||
| return Number(rows[0]?.n || 0); | ||
| }, | ||
| nextBatch: (afterId, limit) => Message.find(afterId | ||
| ? { ...unreconciled, _id: { $gt: new mongoose.Types.ObjectId(afterId) } } | ||
| : unreconciled).sort({ _id: 1 }).limit(limit).lean(), | ||
| // Same pod fallback as the reconciler: a Mongo-only pod is mirrored, then retried. | ||
| copy: (doc) => copyOne(doc).catch(async (err: { code?: string }) => { | ||
| if (err.code !== '23503') throw err; | ||
| await syncPodFromMongo(doc.podId.toString(), doc.userId.toString()); | ||
| return copyOne(doc); | ||
| }), | ||
| }; | ||
| }; | ||
|
|
||
| export async function main(): Promise<void> { | ||
| const apply = process.argv.includes('--apply'); | ||
| const force = process.argv.includes('--force'); | ||
| if (!process.env.MONGO_URI) throw new Error('MONGO_URI is required'); | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require | ||
| const { pool } = require('../config/db-pg') as { pool: PgPool | null }; | ||
| if (!pool) throw new Error('PostgreSQL is not configured (PG_HOST unset)'); | ||
|
|
||
| await mongoose.connect(process.env.MONGO_URI); | ||
| try { | ||
| if (!await ensureLegacyIdIndex()) throw new Error('could not ensure the legacy_mongo_id index'); | ||
| const report = await backfillLocalMessages(productionDeps(pool), { apply, force }); | ||
| console.log(`[backfill-local-messages] ${JSON.stringify(report)}`); | ||
| if (report.refused) { | ||
| console.log(`REFUSED — PostgreSQL already holds ${report.overlap} messages with no Mongo id` | ||
| + ' that predate the newest Mongo message. They may be the same history; copying would duplicate it.' | ||
| + ' Re-run with --force only if you have checked.'); | ||
| } else if (!apply) { | ||
| console.log('DRY RUN — nothing written. Re-run with --apply.'); | ||
| } | ||
| if (report.failed) process.exitCode = 1; | ||
| } finally { | ||
| await mongoose.disconnect(); | ||
| await pool.end(); | ||
| } | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| main().catch((error) => { | ||
| console.error('local message backfill failed:', error); | ||
| process.exit(1); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔎 Supported by static analysis
Script executed:
Repository: Team-Commonly/commonly
Length of output: 17958
Script executed:
Repository: Team-Commonly/commonly
Length of output: 41462
Script executed:
Repository: Team-Commonly/commonly
Length of output: 13385
Script executed:
Repository: Team-Commonly/commonly
Length of output: 16761
Backfill legacy MongoDB messages before mounting PostgreSQL chat routes.
PostgreSQL becomes healthy, and
getMessagesreturns the successful PostgreSQL result without checking MongoDB. The startup reconciler does not migrate existing MongoDB-only rows because it selects only rows withpgFallback: true.syncPodFromMongomirrors only pod metadata and members, not messages. Users upgrading an existing local install can therefore see empty or incomplete chat history.Run an idempotent backfill for all legacy MongoDB messages before serving PostgreSQL-backed reads. Preserve the existing legacy-ID deduplication.