mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
|
||||
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
|
||||
import type { MigrationCheckpoint } from '../src/migration/types.js';
|
||||
|
||||
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;if(databaseUrl&&!local)throw new Error('Migration integration tests only permit localhost');
|
||||
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
|
||||
const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string;
|
||||
const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666';
|
||||
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
|
||||
beforeAll(async()=>{
|
||||
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
|
||||
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
|
||||
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
|
||||
for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);}
|
||||
await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`);
|
||||
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
|
||||
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
|
||||
await source.query('alter table profiles add column avatar_url text');
|
||||
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
|
||||
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
|
||||
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
|
||||
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
|
||||
await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`);
|
||||
await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]);
|
||||
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
|
||||
});
|
||||
afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});});
|
||||
it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});});
|
||||
it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);});
|
||||
it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});});
|
||||
it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});});
|
||||
});
|
||||
Reference in new issue
Block a user